← Back to DevBytes

Testing Strategies for F# Applications

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

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.

— Ad —

Google AdSense will appear here after approval

← Back to all articles