Introduction to Testing in F#
F# is a functional-first language on the .NET platform that emphasizes immutability, pure functions, and type safety. These characteristics make F# applications particularly well-suited for testing, but they also call for testing strategies that differ from traditional object-oriented approaches. In this tutorial, we'll explore how to build a robust testing strategy for F# applications, covering unit testing, property-based testing, integration testing, and best practices.
Why Testing Matters in F#
While F#'s strong type system and immutable data structures eliminate many common bugs at compile time, testing remains essential. Types can express some invariants, but not all business logic can be encoded in the type system. Testing helps you verify runtime behavior, catch edge cases, document expected behavior, and enable safe refactoring. Additionally, F# applications often interact with impure boundaries — databases, APIs, file systems — where testing strategies become critical for maintainability.
Setting Up a Test Project
The most popular testing frameworks in the F# ecosystem are xUnit, NUnit, and Expecto. Expecto is written in F# and offers a functional API that feels natural to F# developers. For this tutorial, we'll primarily use xUnit due to its widespread adoption, but we'll also demonstrate Expecto for property-based testing.
Create a test project with the following commands:
dotnet new console -lang F# -o MyFSharpApp
cd MyFSharpApp
dotnet new xunit -lang F# -o MyFSharpApp.Tests
cd MyFSharpApp.Tests
dotnet add reference ../MyFSharpApp/MyFSharpApp.fsproj
dotnet add package FsUnit
dotnet add package FsCheck
dotnet add package Hedgehog
FsUnit provides expressive assertions that read naturally in F#, while FsCheck and Hedgehog are property-based testing libraries.
Unit Testing Pure Functions
The cornerstone of F# testing is testing pure functions — functions that produce the same output for the same input and have no side effects. Because pure functions are deterministic and isolated, they are trivial to test.
Consider a module that calculates order totals:
module OrderProcessing
type Product = { Name: string; Price: decimal }
type OrderLine = { Product: Product; Quantity: int }
let lineTotal (line: OrderLine) : decimal =
line.Product.Price * decimal line.Quantity
let orderTotal (lines: OrderLine list) : decimal =
lines |> List.sumBy lineTotal
let applyDiscount (rate: decimal) (total: decimal) : decimal =
if rate < 0m || rate > 1m then
invalidArg "rate" "Discount rate must be between 0 and 1"
total * (1m - rate)
Now let's write unit tests using xUnit and FsUnit:
module OrderProcessingTests
open Xunit
open FsUnit.Xunit
open OrderProcessing
[<Fact>]
let ``lineTotal multiplies price by quantity`` () =
let line = {
Product = { Name = "Widget"; Price = 9.99m }
Quantity = 3
}
lineTotal line |> should equal 29.97m
[<Fact>]
let ``orderTotal sums all line totals`` () =
let lines = [
{ Product = { Name = "A"; Price = 10m }; Quantity = 2 }
{ Product = { Name = "B"; Price = 5m }; Quantity = 4 }
]
orderTotal lines |> should equal 40m
[<Fact>]
let ``applyDiscount reduces total by rate`` () =
applyDiscount 0.10m 100m |> should equal 90m
[<Fact>]
let ``applyDiscount throws for invalid rate`` () =
(fun () -> applyDiscount 1.5m 100m |> ignore)
|> should throw typeof<System.ArgumentException>
Notice how readable these tests are. FsUnit's should equal and should throw combinators make assertions feel like natural language. Always prefer testing pure functions directly — no mocks, no setup, no state.
Property-Based Testing
Property-based testing is a powerful technique where you specify properties that should hold for all valid inputs, and the framework generates hundreds of random test cases to verify them. This approach often uncovers edge cases you wouldn't think to test manually. F# developers frequently use FsCheck for this purpose.
Here's an example using FsCheck with xUnit integration:
open FsCheck
open FsCheck.Xunit
open OrderProcessing
[<Property>]
let ``lineTotal is always non-negative for non-negative inputs`` (price: decimal, qty: int) =
let safePrice = abs price
let safeQty = abs qty
let line = {
Product = { Name = "Test"; Price = safePrice }
Quantity = safeQty
}
lineTotal line >= 0m
[<Property>]
let ``orderTotal of empty list is zero`` () =
orderTotal [] = 0m
[<Property>]
let ``applyDiscount with zero rate returns original total`` (total: decimal) =
let safeTotal = abs total
applyDiscount 0m safeTotal = safeTotal
[<Property>]
let ``orderTotal is order-independent`` (lines: OrderLine list) =
let shuffled = lines |> List.rev
orderTotal lines = orderTotal shuffled
FsCheck automatically generates random values for each parameter. The last property is particularly valuable — it verifies that summation is commutative, a mathematical property that should always hold. If FsCheck finds a counterexample, it shrinks it to the minimal failing case, making debugging easier.
You can also create custom generators for domain-specific types:
open FsCheck
type Product = { Name: string; Price: decimal }
type OrderLine = { Product: Product; Quantity: int }
type Generators =
static member OrderLine =
Arb.generate<decimal>
|> Gen.map abs
|> Gen.map (fun price ->
{ Product = { Name = "Gen"; Price = price }
Quantity = 1 })
|> Arb.fromGen
// Register in test setup:
Arb.register<Generators>()
Testing Impure Functions and Side Effects
Not all F# code is pure. When dealing with side effects — I/O, database access, HTTP calls — you need strategies to keep tests fast and deterministic. The two main approaches are dependency injection via functions and the effect pattern.
Dependency Injection via Functions
In F#, you can pass dependencies as function parameters rather than using interfaces and DI containers. This keeps code testable without ceremony:
module UserService
type User = { Id: int; Email: string; Name: string }
type IUserRepository =
abstract member FindById: int -> User option
abstract member Save: User -> unit
let updateUserEmail (repo: IUserRepository) (userId: int) (newEmail: string) =
match repo.FindById userId with
| Some user ->
let updated = { user with Email = newEmail }
repo.Save updated
Ok updated
| None ->
Error "User not found"
In tests, provide a fake implementation:
open Xunit
open FsUnit.Xunit
open UserService
type FakeRepository(users: User list, mutable saved: User list) =
interface IUserRepository with
member _.FindById id =
users |> List.tryFind (fun u -> u.Id = id)
member _.Save user =
saved <- user :: saved
[<Fact>]
let ``updateUserEmail updates existing user`` () =
let user = { Id = 1; Email = "old@test.com"; Name = "Alice" }
let saved = ref []
let repo = FakeRepository([user], [])
let result = updateUserEmail repo 1 "new@test.com"
match result with
| Ok u -> u.Email |> should equal "new@test.com"
| Error _ -> Assert.True(false, "Expected success")
[<Fact>]
let ``updateUserEmail returns error for missing user`` () =
let repo = FakeRepository([], [])
let result = updateUserEmail repo 99 "new@test.com"
result |> should equal (Error "User not found")
The Effect Pattern
For more advanced scenarios, you can model side effects as data using computation expressions or an interpreter pattern. This separates what to do from how to do it:
type Effect<'a> =
| ReadFile of string * (string -> 'a)
| WriteFile of string * string * (unit -> 'a)
| HttpGet of string * (string -> 'a)
let interpret (effect: Effect<'a>) : 'a =
match effect with
| ReadFile (path, cont) -> cont (System.IO.File.ReadAllText path)
| WriteFile (path, content, cont) -> cont (System.IO.File.WriteAllText(path, content))
| HttpGet (url, cont) ->
use client = new System.Net.Http.HttpClient()
cont (client.GetStringAsync(url).Result)
In tests, you provide a test interpreter that returns canned responses, eliminating all real I/O. This pattern is powerful but adds complexity, so use it judiciously.
Integration Testing
Integration tests verify that multiple components work together correctly, including external systems. For F# web applications using Giraffe or Saturn, you can use Microsoft.AspNetCore.TestHost to run the app in-process:
open System.Net.Http
open Microsoft.AspNetCore.TestHost
open Microsoft.AspNetCore.Hosting
open Xunit
type IntegrationTests() =
let builder =
WebHostBuilder()
.UseStartup<App.Startup>()
let server = new TestServer(builder)
let client = server.CreateClient()
[<Fact>]
member _.``GET /health returns 200`` () =
let response = client.GetAsync("/health").Result
response.IsSuccessStatusCode |> should' be True
[<Fact>]
member _.``GET /api/users returns JSON`` () =
let response = client.GetAsync("/api/users").Result
let content = response.Content.ReadAsStringAsync().Result
content |> should contain "users"
interface System.IDisposable with
member _.Dispose() =
client.Dispose()
server.Dispose()
For database integration tests, use a real database in a Docker container or a test-specific schema. Tools like Testcontainers make spinning up disposable databases straightforward:
open Testcontainers.PostgreSql
open Xunit
type DatabaseFixture() =
let container = new PostgreSqlContainer("postgres:15-alpine")
do container.StartAsync().Wait()
member _.ConnectionString = container.GetConnectionString()
interface System.IDisposable with
member _.Dispose() = container.DisposeAsync().AsTask().Wait()
type UserRepositoryTests(fixture: DatabaseFixture) =
let connStr = fixture.ConnectionString
[<Fact>]
member _.``Save and retrieve user round-trips correctly`` () =
// Initialize schema, insert, query, assert
()
Snapshot Testing
Snapshot testing captures the output of a function and compares it against a stored baseline. This is useful for complex outputs like serialized JSON or generated HTML. While more common in JavaScript ecosystems, you can implement snapshot testing in F# using simple file comparisons:
open System.IO
let snapshot (name: string) (actual: string) =
let snapshotDir = "__snapshots__"
Directory.CreateDirectory(snapshotDir) |> ignore
let path = Path.Combine(snapshotDir, name + ".snap")
if File.Exists path then
let expected = File.ReadAllText path
if actual <> expected then
failwithf "Snapshot mismatch for %s.\nExpected:\n%s\nActual:\n%s" name expected actual
else
File.WriteAllText(path, actual)
[<Fact>]
let ``serialized order matches snapshot`` () =
let order = { /* ... */ }
let json = serializeOrder order
snapshot "order-serialization" json
Best Practices
- Prefer pure functions: Design your core domain logic as pure functions. This makes them trivially testable without mocks or setup. Push side effects to the edges of your application.
- Test behavior, not implementation: Focus on what the code does, not how it does it. This keeps tests resilient to refactoring.
- Use property-based testing for invariants: Whenever you can express a property that should always hold, use FsCheck or Hedgehog instead of writing individual test cases.
- Name tests descriptively: Use backtick-quoted names in F# to make test names read like specifications:
``order total excludes negative quantities``. - Keep tests fast: Unit tests should run in milliseconds. If tests are slow, you likely have unnecessary I/O or coupling. Use fakes and in-memory implementations.
- Test the boundary between pure and impure code: Ensure your interpreters and side-effect handlers are covered by integration tests, while pure logic is covered by fast unit tests.
- Use the type system to make invalid states unrepresentable: The fewer invalid states your types allow, the fewer tests you need. Discriminated unions and single-case DU wrappers are powerful tools here.
- Organize tests to mirror source structure: If you have
OrderProcessing.fs, createOrderProcessingTests.fs. This makes navigation intuitive. - Don't test the framework: Avoid testing that
List.mapworks or that the F# compiler behaves correctly. Focus on your domain logic. - Adopt a testing pyramid: Have many fast unit tests, fewer integration tests, and a small number of end-to-end tests. This balances confidence with execution speed.
Conclusion
Testing F# applications leverages the language's strengths — purity, immutability, and strong typing — to produce reliable, maintainable test suites. By favoring pure functions in your domain layer, you make unit testing straightforward and fast. Property-based testing with FsCheck or Hedgehog lets you express invariants concisely and discover edge cases automatically. For impure boundaries, function-based dependency injection and the effect pattern keep tests deterministic without heavy mocking frameworks. Integration tests with TestHost and containerized databases verify that components compose correctly in real-world conditions. By combining these strategies and following best practices like descriptive test naming, a healthy test pyramid, and leveraging the type system to eliminate invalid states, you can build F# applications with high confidence and low maintenance burden. The result is a codebase where refactoring feels safe, bugs are caught early, and the tests themselves serve as living documentation of your system's behavior.