Introduction to Functional Programming in F# – Part 4

Introduction

Welcome to the fourth post in this introductory series on functional programming in F#. In this post we will be building on the function composition concepts we worked through in the previous post and we'll be writing our first unit tests in F#. As an added bonus we will also start looking at how we can separate our code into discrete modules.

Getting Started

We're going to use Visual Studio 2019 Community Edition for this post but everything will also work in VSCode + ionide and Jetbrains Rider.

  1. Create a new folder to store your code.

  2. From the Start screen, select F# from the languages dropdown and Library from the Project Type dropdown. Select the Class Library (.NET Core) option and click Next. Name the project DemoCode and the solution MyDemoCode and select your new folder from the selector. Click on Create.

  3. Once the project has been created, right click on the solution and select Add -> New Project. Select F# from the language dropdown and Test from the Project Type dropdown. Select the xUnit Test Project (.NET Core) and click next. Call the project DemoCodeTests and make sure the folder is the same as for the other project. Click on Create.

  4. We need to add/update some NuGet packages. Right click on the solution and choose Manage Nuget Packages for Solution. Update any packages NuGet informs you of and install the FsUnit and FsUnit.Xunit packages to the test project.

  5. Rename Library.fs in the DemoCode project to Customer.fs and rename Tests.fs in the test project to CustomerTests.fs.

  6. Right click on Dependencies in the test project and add a Project Reference to the DemoCode project.

  7. Build the solution.

Now that we have everything set up, let's start with the code.

Namespaces and Modules

Open Customer.fs in the DemoCode project, clear any existing code and type in the following at the top:

namespace DemoCode

Each F# file (.fs) will have a namespace at the top. It serves the same purpose as it does in the rest of the .Net ecosystem.

Next we add the type definitions:

[<AutoOpen>]
module CustomerTypes =

    type Customer = { 
        CustomerId : string
        IsRegistered : bool
        IsEligible : bool 
    }

    type CreateCustomerException =
        | RemoteServerException of exn
        | CustomerAlreadyExistsException

A module is a container that we can group related things into, in this case the customer type definitions. The AutoOpen attribute means that the module is loaded and available to all the following code. It's a useful option for common types or functions. Exn is the built-in F# exception type.

Next we add another module below the CustomerTypes module that contains a dummy datastore. We will be looking at using a real database later in another post.

module Db =

    let tryGetCustomer customerId = // String -> Result
<Customer option,CreateCustomerException>
        try
            [
                { CustomerId = "John"; IsRegistered = true; 
IsEligible = true }
                { CustomerId = "Mary"; IsRegistered = true; 
IsEligible = true }
                { CustomerId = "Richard"; IsRegistered = true; 
IsEligible = false }
                { CustomerId = "Sarah"; IsRegistered = false; 
IsEligible = false }
            ]
            |> List.tryFind(fun c -> c.CustomerId = customerId)
            |> Ok
        with
        | ex -> Error (RemoteServerException ex)

    let saveCustomer (customer:Customer) = // Customer -> unit
        try
            //Save customer
            Ok ()
        with
        | ex -> Error (RemoteServerException ex)

The square brackets signify an F# list - in this case a list of customers. We will look at collections in more detail in the next post in this series.

Let's add a function to upgrade a customer to Eligible in a module below the Db module:

module Customer =

    let convertToEligible customer = // Customer -> Customer
        if not customer.IsEligible then { customer with 
IsEligible = true }
        else customer

Note that we are returning the original customer if they are already eligible for the purposes of this example code but we would probably handle that use case differently in a real program.

The next stage is to write an upgrade customer function that used the Db module:

let upgradeCustomer customerId =
        customerId
        |> Db.tryGetCustomer 
        |> convertToEligible // Problem
        |> Db.saveCustomer

At the moment we have a problem because the output of Db.tryGetCustomer (Result) is different that the input of convertToEligible (Customer). Let's create a function that would allow us to compose these two functions together:

let tryConvertToEligible customer =
    match customer with
    | Some c -> Some (convertToEligible c)
    | None -> None

This looks very similar to the map function we built in the last post about Result:

let map f fResult =
        match fResult with
        | Ok x -> Ok (f x)
        | Error ex -> Error ex

Let's modify our function so that it matches the style of the map function:

let tryConvertToEligible convertToEligible customer =
    match customer with
    | Some c -> Some (convertToEligible c)
    | None -> None

Now let's do some renaming:

let map f fOption =
    match fOption with
    | Some x -> Some (f x)
    | None -> None

As with the Result.map, there is also an Option.map as it is such a common thing. We still need to fix the Result issue which we do by using Result.map as convertToEligible doesn't output a Result:

let upgradeCustomer customerId =
    customerId
    |> Db.tryGetCustomer 
    |> Result.map (Option.map convertToEligible)
    |> Db.saveCustomer // Problem

As before, fixing one problem has led to the next one! Again, let's write a function to deal with the option issue first.

let trySaveCustomer customer =
    match customer with
    | Some c -> c |> Db.saveCustomer
    | None -> Ok ()

This works but isn't testable without the Db. Let's pass (inject) the service as a function into our new function:

let trySaveCustomer saveCustomer customer = // (Customer 
-> Result<unit,CreateCustomerException>) -> Customer option 
-> Result<unit,CreateCustomerException>
    match customer with
    | Some c -> c |> saveCustomer
    | None -> Ok ()

Then we need to plug the new function into the pipeline with a Result.bind:

let upgradeCustomer customerId =
    customerId
    |> Db.tryGetCustomer 
    |> Result.map (Option.map convertToEligible)
    |> Result.bind (trySaveCustomer Db.saveCustomer)

We have now fixed the errors and should have a Customer module that looks like this:

module Customer =

    let trySaveCustomer saveCustomer customer =
        match customer with
        | Some c -> c |> saveCustomer
        | None -> Ok ()

    let convertToEligible customer =
        if not customer.IsEligible then { customer with 
IsEligible = true }
        else customer

    let upgradeCustomer customerId =
        customerId
        |> Db.tryGetCustomer 
        |> Result.map (Option.map convertToEligible)
        |> Result.bind (trySaveCustomer Db.saveCustomer)

Now we can add a simple assert to check that it works. Rather than add the assert into the Customer.fs file, we will create a new script file called Asserts.fsx in the DemoCode project by right-clicking on the DemoCode project an selecting Add -> New item and selecting a Script File (.fsx) and call it CustomerAsserts.fsx. Once this has been done we add the following code to the top of the file:

#load "customer.fs"

This loads the code from Customer.fs. Highlight the line and press ALT + ENTER to load the code into FSI. It may take a few seconds. Now add the following code below the #load line and run in FSI:

open DemoCode.CustomerTypes
open DemoCode.Customer

let assertUpgrade =
    let customer = { CustomerId = "John"; IsRegistered = true; 
IsEligible = false }
    let upgraded = convertToEligible customer
    upgraded = { customer with IsEligible = true }

Note that the open statements use .

The code in Tests.fsx will not get compiled into the output dll but it allows us to separate simple test/assert code from our production code. Run the assert in FSI.

Creating Unit Tests

Copy the following code into CustomerTests.fs in the DemoCodeTests project.

namespace MyCodeTests

open Xunit
open FsUnit
open DemoCode.CustomerTypes
open DemoCode.Customer

module ``Convert customer to eligible`` =

    let sourceCustomer = { CustomerId = "John"; 
IsRegistered = true; IsEligible = true }

    [<Fact>]
    let ``should succeed if not currently eligible`` () =
        let customer = {sourceCustomer with IsEligible = false}
        let upgraded = convertToEligible customer
        upgraded |> should equal sourceCustomer

    [<Fact>]
    let ``should return eligible customer unchanged`` () =
        let upgraded = convertToEligible sourceCustomer
        upgraded |> should equal sourceCustomer

Rather than re-invent the wheel, in this case we are using Xunit as our test framework but we are using FsUnit for assertions as they are easier to read.

The nice thing about F# for naming things, particularly tests, is that we can use the double backtick to allow us to use readable sentences rather than cased code.

If you build the solution, you will be able to see the tests in VS 2019 Test Explorer. Run the tests and watch them go green. Change the IsEligible value to false on the sourceCustomer and run the tests again. Have a look at the way that the errors are reported in Test Explorer. Revert the IsEligible value back to true.

Extending the Functionality

Let's add a couple of functions to register a customer:

let createCustomer customerId =
    { CustomerId = customerId; IsRegistered = true; 
IsEligible = false }

let registerCustomer customerId =
    customerId
    |> Db.tryGetCustomer 
    |> createCustomer // Problem
    |> Db.saveCustomer

Again we have a small problem as the output of Db.tryGetCustomer (Result) doesn't match the input of createCustomer (string), so we need to create a new function to compose them together that returns an exception if a customer with that Id already exists:

let tryCreateCustomer customerId (customer:Customer option) =
    match customer with
    | Some _ -> Error CustomerAlreadyExistsException
    | None -> Ok (createCustomer customerId)

There isn't anything built into F# to help with this but it is a common pattern in this style of programming.

Our new adaptor function (tryCreateCustomer) returns a Result, so we need to use Result.bind rather than Result.map. This fixes the initial problem but shows the next one on the save to the database:

let registerCustomer customerId =
    customerId
    |> Db.tryGetCustomer 
    |> Result.bind (tryCreateCustomer customerId)
    |> Db.saveCustomer // Problem

Fortunately, this is a really simple fix - just add a Result.bind - as tryCreateCustomer returns a Customer on the Ok track and saveCustomer takes a Customer:

let registerCustomer customerId =
    customerId
    |> Db.tryGetCustomer 
    |> Result.bind (tryCreateCustomer customerId)
    |> Result.bind Db.saveCustomer

We can write a simple assert function in the CustomerAsserts.fsx file:

let assertCreated =
    let existing, name = None, "Ian"
    let result = tryCreateCustomer name existing
    match result with
    | Ok customer -> customer = { CustomerId = name; 
IsRegistered = true; IsEligible = false }
    | Error _ -> false

Highlight the #load line and run in FSI and then highlight the rest of the code and run that in FSI. You should get two true statements.

Adding Unit Tests for Register Customer

Add the following code between the open statements and the module in the CustomerTests.fs file. These are simple helper functions to make the next tests more readable:

[<AutoOpen>]
module TestHelpers =

    let failTest msg = 
        Assert.True(false, msg)

    let passTest =
        Assert.True(true)

    let isCustomerAlreadyExistsException exn =
        match exn with
        | CustomerAlreadyExistsException -> passTest
        | ex -> failTest (sprintf "%A not expected" ex)

Now we can add the unit tests for registration below the existing module:

module ``Create customer`` =

    let name = "John"

    [<Fact>]
    let ``should succeed if customer does not exist`` () =
        let existing = None
        let result = (name, existing) ||> tryCreateCustomer 
        match result with
        | Ok customer -> customer |> should equal 
{ CustomerId = name; IsRegistered = true; IsEligible = false }
        | Error ex -> ex.ToString() |> failTest

    [<Fact>]
    let ``should fail if customer does exist`` () =
        let existing = Some { CustomerId = name; 
IsRegistered = true; IsEligible = false }
        let result = (name, existing) ||> tryCreateCustomer 
        match result with
        | Error ex -> isCustomerAlreadyExistsException ex
        | Ok customer -> sprintf "%A was not expected" 
customer |> failTest

The only new feature in these tests is the ||> operator which passes a tuple of two items as two individual arguments to the function on the right.

Now that we have tests, we can delete the asserts in CustomerAsserts.fsx as they are no longer needed.

The Completed Codebase

The following is what you should now have in Customer.fs:

namespace DemoCode

[<AutoOpen>]
module CustomerTypes =

    type Customer = { 
        CustomerId : string
        IsRegistered : bool
        IsEligible : bool 
    }

    type CreateCustomerException =
        | RemoteServerException of exn
        | CustomerAlreadyExistsException

module Db =

    let tryGetCustomer customerId =
        try
            [
                { CustomerId = "John"; IsRegistered = true; 
IsEligible = true }
                { CustomerId = "Mary"; IsRegistered = true; 
IsEligible = true }
                { CustomerId = "Richard"; IsRegistered = true; 
IsEligible = false }
                { CustomerId = "Sarah"; IsRegistered = false; 
IsEligible = false }
            ]
            |> List.tryFind (fun c -> 
c.CustomerId = customerId)
            |> Ok
        with
        | ex -> Error (RemoteServerException ex)

    let saveCustomer (customer:Customer) =
        try
            //Save customer
            Ok ()
        with
        | ex -> Error (RemoteServerException ex)

module Customer =

    let trySaveCustomer saveCustomer customer =
        match customer with
        | Some c -> c |> saveCustomer
        | None -> Ok ()

    let createCustomer customerId =
        { CustomerId = customerId; IsRegistered = true; 
IsEligible = false }

    let tryCreateCustomer customerId (customer:Customer option) 
= match customer with
        | Some _ -> Error CustomerAlreadyExistsException
        | None -> Ok (createCustomer customerId)

    let convertToEligible customer =
        if not customer.IsEligible then { customer with 
IsEligible = true }
        else customer

    let upgradeCustomer customerId =
        customerId
        |> Db.tryGetCustomer 
        |> Result.map (Option.map convertToEligible)
        |> Result.bind (trySaveCustomer Db.saveCustomer)

    let registerCustomer customerId =
        customerId
        |> Db.tryGetCustomer 
        |> Result.bind (tryCreateCustomer customerId)
        |> Result.bind Db.saveCustomer

The following is what you should now have in CustomerTests.fs:

namespace MyCodeTests

open Xunit
open FsUnit
open DemoCode.CustomerTypes
open DemoCode.Customer

[<AutoOpen>]
module TestHelpers =

    let failTest msg = 
        Assert.True(false, msg)

    let passTest =
        Assert.True(true)

    let isCustomerAlreadyExistsException exn =
        match exn with
        | CustomerAlreadyExistsException -> passTest
        | ex -> failTest (sprintf "%A not expected" ex)

module ``Convert customer to eligible`` =

    let sourceCustomer = { CustomerId = "John"; 
IsRegistered = true; IsEligible = true }

    [<Fact>]
    let ``should succeed if not currently eligible`` () =
        let customer = {sourceCustomer with IsEligible = false}
        let upgraded = convertToEligible customer
        upgraded |> should equal sourceCustomer

    [<Fact>]
    let ``should return eligible customer unchanged`` () =
        let upgraded = convertToEligible sourceCustomer
        upgraded |> should equal sourceCustomer

module ``Create customer`` =

    let name = "John"

    [<Fact>]
    let ``should succeed if customer does not exist`` () =
        let existing = None
        let result = tryCreateCustomer name existing
        match result with
        | Ok customer -> customer |> should equal 
{ CustomerId = name; IsRegistered = true; IsEligible = false }
        | Error ex -> failTest <| ex.ToString()

    [<Fact>]
    let ``should fail if customer does exist`` () =
        let existing = Some { CustomerId = name; 
IsRegistered = true; IsEligible = false }
        let result = tryCreateCustomer name existing
        match result with
        | Error ex -> isCustomerAlreadyExistsException ex
        | Ok customer -> failTest 
(sprintf "%A was not expected" customer)

Summary

I hope you enjoyed this post. The features we used are important if we want to write bigger codebases in F#.

  • Namespaces and modules

  • Unit testing with Xunit

  • Assertions with FsUnit

In the next post in this series, we will have a look at collections.

Part 3 Table of Contents Part 5

Zurück
Zurück

Introduction to Functional Programming in F# – Part 5

Weiter
Weiter

Introduction to Functional Programming in F# – Part 3