all Technical posts

Practically Property-Based Testing your Strict Domain Model in F# & C#

Property-based testing has been around some time, just as has domain modeling. However, the two don't always find each other in practical examples and projects.

This post will include domain modeling and property-based testing, but is by no intent an introduction to one or the other.

Strict Domain Modeling

By domain modeling, I mean a data model that holds strict specifications that are validated when creating the model. The ‘strict’ part here is to emphasize the need for strong and hardened models. Domain models can be corrupted, for example, if they can be created without validation or if the validation is not representing the domain.

Property-based testing is an excellent tool to verify if functionality works as expected — covering all grounds and all possibilities. So, why not use that in the most important and critical part of the project?

This post will use the Covid situation as a way to describe some of the simplified restrictions in place by the Belgium government. When going to events, one needs a Corona pass for entrance. This pass can represent a valid vaccination, a test result, or a certificate of Covid recovery. Only adults require such a pass. Note that I used my own validation library, FPrimitive, to describe this model, Expecto to describe the tests, and FsCheck to describe the properties and generators.

open FPrimitive
type Age =
private Age of int with
static member create x = specModel Age x {
inclusiveBetween 1 120 "age should be between 1-120 years (inclusive)" }
static member value (Age x) = x
type TestResult =
| Positive
| Negative with
static member create x =
Spec.def
|> Spec.verify (fun x -> Union.isUnionCase<TestResult> (x)) "test result should be either 'Positive' or 'Negative'"
|> Spec.createModelWith Union.create<TestResult> x
type RecoveryTime =
private RecoveryTime of TimeSpan with
static member create x = specModel RecoveryTime x {
greaterThanOrEqual TimeSpan.Zero "recovery time should be a positive time range" }
type CoronaPass =
| Vaccinated
| Tested of TestResult
| Recovered of RecoveryTime
type Guest =
{ Age : Age
Pass : CoronaPass option } with
static member create age pass = result {
if Age.value age < 12 && Option.isSome pass
then return! Spec.error "age-pass" "only adults can have a corona pass"
else return { Age = age; Pass = pass } }

Generating Strict Models

Before we jump into the testing part of this blog, we should talk about generators. As you can see, we can’t just generate a random integer for the guest’s age or use any time span for the recovery period. This model can only be created with valid inputs, so we need to provide those in our generators.

A good tip in doing this is to split your generation models, just like in the domain, so you can easily access generator combinators.

open FsCheck
module Gen =
module TimeSpan =
let positive =
Arb.generate<TimeSpan>
|> Gen.map (fun x -> if x < TimeSpan.Zero then x.Negate() else x)
let negative =
positive |> Gen.map (fun x -> x.Negate())
let choose (min : TimeSpan, max : TimeSpan) =
Gen.choose (int min.Ticks, int max.Ticks)
|> Gen.map (int64 >> TimeSpan.FromTicks)
module Age =
let private asAge = Gen.map (Age.create >> Result.get)
let any = Gen.choose (1, 119) |> asAge
let child = Gen.choose (1, 11) |> asAge
let adult = Gen.choose (12, 119) |> asAge
module Pass =
let private asPassOption x = Result.map x >> Result.toOption
let vaccinated = Gen.constant (Some Vaccinated)
let tested (result : TestResult) = gen {
return TestResult.create (string result) |> asPassOption Tested }
let testedAny =
Gen.elements Union.cases<TestResult>
|> Gen.map (TestResult.create >> asPassOption Tested)
let recoveredSince time = gen {
return RecoveryTime.create time |> asPassOption Recovered }
let recovered = TimeSpan.positive >>= recoveredSince
let none = Gen.constant None
let any = Gen.oneof [ none; vaccinated; testedAny; recovered ]
module Guest =
let create age pass =
Gen.map2 Guest.create age pass
|> Gen.map Result.get
let child = create Age.child Pass.none
let adult = create Age.adult Pass.any
let one = Gen.oneof [ child; adult ]
let list = Gen.listOf one
let listWith age pass =
create age pass
|> Gen.listOf

As you can see, the different ways of creating a guest are all present here. If you want to generate an adult guest with a Corona pass, use Gen.Guest.adult. If you want to generate a list of guests of any age and pass, use Gen.Guest.list, and so forth.

When the domain changes, it will impact these generators. The user-friendly specification validation error in our domain will be shown in our test output, if that happens. This makes it a very tester-friendly approach.

Testing Domain Specifications

Now that we have our domain and generators to generate a version of the domain, we can test our domain specifications in properties. These are very straightforward properties. The trick is to find the constants. Our guest’s age expects an integer between 1-120. What constants are there? Any number below 1 or 120 is wrong, any number between 1-120 is right. That’s how simple this is.

open Expecto
open FsCheck
let withGen g f =
Arb.fromGen g |> Prop.forAll <| f
[<Tests>]
let modelTests =
testList "model tests" [
testProperty "age should be between 1-120" <| fun () ->
let age = Gen.choose (1, 120)
withGen age (Age.create >> Result.isOk)
testProperty "age should not be outside 1-120" <| fun () ->
let age = Gen.oneof [
Gen.choose (Int32.MinValue, 0)
Gen.choose (120, Int32.MaxValue) ]
withGen age (Age.create >> Result.isError)
testProperty "pass recovery should be positive time range" <| fun () ->
withGen Gen.TimeSpan.positive <| fun time ->
Result.isOk (RecoveryTime.create time)
testProperty "pass recovery should not be negative time ramge" <| fun () ->
withGen Gen.TimeSpan.negative <| fun time ->
Result.isError (RecoveryTime.create time)
testProperty "pass tested should have positive or negative result" <| fun x ->
let results = Gen.elements Union.cases<TestResult>
withGen results <| fun y ->
let expected = Result.isOk (Union.create<TestResult> y)
let actual = Result.isOk (TestResult.create x)
expected = actual

Testing Trust Boundaries

One of the most simple test properties to write when working with your domain is the Round-Trip Property. Usually, to interact with the outside world, you need a DTO model (Data Transfer Object). This model is made for serialization and is efficient in data transfer, and easy to create and batch, etc. This can be a totally different model to your domain. Internally, a mapping has to be made between the two. The mapping can be tested easily with this property.

Let’s introduce our DTO model and our mapping towards the domain:

type RecoveryJson =
{ RecoverTime : TimeSpan }
type GuestJson =
{ Age : int
IsVaccinated : bool
TestResult : string
Recovery : RecoveryJson option }
module Dto =
let toGuest (json : GuestJson) = result {
let! age = Age.create json.Age
let! pass =
match json with
| j when j.IsVaccinated -> Ok (Some Vaccinated)
| j when j.TestResult <> null -> TestResult.create j.TestResult |> Result.map (Tested >> Some)
| { Recovery = Some { RecoverTime = time } } -> RecoveryTime.create time |> Result.map (Recovered >> Some)
| _ -> Ok None
return! Guest.create age pass }
let ofGuest (model : Guest) =
let testResult =
match model.Pass with
| Some (Tested result) -> string result
| _ -> null
let recoveryTime =
match model.Pass with
| Some (Recovered (RecoveryTime time)) -> Some { RecoverTime = time }
| _ -> None
{ Age = Age.value model.Age
IsVaccinated = Option.exists ((=) Vaccinated) model.Pass
TestResult = testResult
Recovery = recoveryTime }

What a Round-Trip Property means is that we test it, we can map between the DTO and the domain model and back. Hence, the name ‘Round-Trip’. This property is proof that we don’t lose any information in the mapping process.

testProperty "round-trip guest model to guest dto" <| fun () ->
withGen Gen.Guest.one <| fun expected ->
let actual = Dto.ofGuest expected |> Dto.toGuest
Ok expected = actual |@ "mapping back and forth between guest model and guest dto should not loose information"

Testing Domain Constants

Now that we have the general part out of the way, we can start with some more ‘advanced’ work. First, let’s define a concert which people can attend only if they can prove they are valid guests according to the Covid restrictions.

module Restrict =
let age x = Age.value x < 12
let pass (x : CoronaPass option) =
let twoWeeks = TimeSpan.FromDays 14.
match x with
| Some Vaccinated
| Some (Tested Negative) -> true
| Some (Recovered (RecoveryTime time)) when time < twoWeeks -> true
| _ -> false
type Doors = Allow | Prohibit
module Concert =
let attend guests =
List.map (fun g ->
if Restrict.age g.Age || Restrict.pass g.Pass
then Allow, g
else Prohibit, g) guests

Put really simply: children are allowed in by default, and adults are only allowed in with a pass that says they’re vaccinated, have tested negative, or have recovered from Covid. In those cases, the concert will provide them with an Allow door.

Now you’ll see the true power of properties. The exact way of describing the model can be used to describe the properties. It’s that simple.

<Tests>]
let concertTests =
testList "concert tests" [
testProperty "childs are always allowed" <| fun () ->
let childs = Gen.listOf Gen.Guest.child
withGen childs <| fun guests ->
Concert.attend guests = List.Tuple.insertFst Allow guests |@ "all childs are allowed at concerts"
testProperty "adults without pass are never allowed" <| fun () ->
let adultsWithNoPass = Gen.Guest.listWith Gen.Age.adult Gen.Pass.none
withGen adultsWithNoPass <| fun guests ->
Concert.attend guests = List.Tuple.insertFst Prohibit guests |@ "adults without pass are never allowed at concerts"
testProperty "adults that are vaccinated are always allowed" <| fun () ->
let vaccinatedAdults = Gen.Guest.listWith Gen.Age.adult Gen.Pass.vaccinated
withGen vaccinatedAdults <| fun guests ->
Concert.attend guests = List.Tuple.insertFst Allow guests
testProperty "adults that are tested negative are always allowed" <| fun () ->
let testedNegative = Gen.Guest.listWith Gen.Age.adult (Gen.Pass.tested Negative)
withGen testedNegative <| fun guests ->
Concert.attend guests = List.Tuple.insertFst Allow guests
testProperty "adults that are tested positive are never allowed" <| fun () ->
let testedPositive = Gen.Guest.listWith Gen.Age.adult (Gen.Pass.tested Positive)
withGen testedPositive <| fun guests ->
Concert.attend guests = List.Tuple.insertFst Prohibit guests
testProperty "adults that are recovered from covid more than two weeks are never allowed" <| fun () ->
let twoWeeks = TimeSpan.FromDays 14.
let recoveredAdults =
Gen.TimeSpan.positive
|> Gen.map ((+) twoWeeks)
>>= Gen.Pass.recoveredSince
|> Gen.Guest.listWith Gen.Age.adult
withGen recoveredAdults <| fun guests ->
Concert.attend guests = List.Tuple.insertFst Prohibit guests
testProperty "adults that are recovered from covid within two weeks are always allowed" <| fun () ->
let twoWeeks = TimeSpan.FromDays 14.
let recoveredAdults =
Gen.TimeSpan.choose (TimeSpan.Zero, twoWeeks)
>>= Gen.Pass.recoveredSince
|> Gen.Guest.listWith Gen.Age.adult
withGen recoveredAdults <| fun guests ->
Concert.attend guests = List.Tuple.insertFst Allow guests
testProperty "all guest are checked" <| fun () ->
withGen Gen.Guest.list <| fun guests ->
Concert.attend guests |> List.map snd = guests
testProperty "all guest are checked the same" <| fun () ->
withGen Gen.Guest.list <| fun guests ->
let first = Concert.attend guests
let second = List.map snd first |> Concert.attend
first = second

What these properties also verify is that all the attended guests are checked and are always the same (i.e: guest that shows up twice).

Conclusion

Easys. FsCheck has a way of describing properties while using the C# xUnit test framework. The way to write them is a bit different and more verbose (because it’s C#) but the same properties can be achieved.

In this post, we saw what the possibilities are for practically using Property-Based Testing in a Domain Model. The two should definitely be used in close quarters, as they both talk about restrictions, validations, specifications, and ways to enforce all of these. Don’t be shy — ask for using any property-based testing framework in your project. You’ll see that this is not only very practical, but improves the code base greatly.

Thanks for reading,
Stijn

Subscribe to our RSS feed

Hi there,
how can we help?

Got a project in mind?

Connect with us

Let's talk

Let's talk

Thanks, we'll be in touch soon!

Call us

Thanks, we've sent the link to your inbox

Invalid email address

Submit

Your download should start shortly!

Stay in Touch - Subscribe to Our Newsletter

Keep up to date with industry trends, events and the latest customer stories

Invalid email address

Submit

Great you’re on the list!