Property-based testing is a technique that allows testing pure functions totally instead of relying on arbitrary values. This article highlights how it can be applied to the domain layer of a clean architecture.
Introduction
A lot has been written about clean architecture. Its main value is the ability to maintain free from side effects domain layer that allows us to test core business logic without leveraging heavy mocks.
However, when it comes to designing tests for pure domain logic, quite often, we don’t tend to be so picky. Unit testing contains many traps such as overspecified software. But even when it comes to testing pure functions which may seem as a pretty straightforward process, we may encounter some pitfalls.
One of them is that when writing unit tests, we rely on some sort of arbitrary magic numbers. While we may guarantee that our function works correctly at given points, we can’t guarantee that it works at every point. An alternative would be to check whether the function satisfies some criteria continuously.
And this is what property-based testing is aimed at. Instead of verifying output at hardcoded input points, it checks the properties of the function you define with a multitude of generated values.
Let’s look at the code example to see how that works. Below is the example from my project Kyiv Station Walk. You can see the function that takes a collection of checkpoints from the domain and transforms it so it conforms to the rules of the presentation layer.
let removeRedundantCheckpoints (checkPoints : Location[]) =
let checkPointsMaxCount = 5
let isStartOrEndOfTheRoute (checkPoints : Location[]) i =
i = 0 || i = checkPoints.Length - 1
let euclidianDistance c1 c2 =
Math.Pow(float(c1.lattitude - c2.lattitude), float(2)) +
Math.Pow(float(c1.longitude - c2.longitude), float(2))
if checkPoints.Length <= 5 then
checkPoints
else
checkPoints
|> Array.mapi(fun i c ->
if isStartOrEndOfTheRoute checkPoints i then
{
index = i
checkPoint = c
distanceToNextCheckPoint = float(1000000)
}
else
{
index = i
checkPoint = c
distanceToNextCheckPoint = euclidianDistance checkPoints.[i+1] c
}
)
|> Array.sortByDescending(fun i -> i.distanceToNextCheckPoint)
|> Array.take(checkPointsMaxCount)
|> Array.sortBy(fun i -> i.index)
|> Array.map(fun i -> i.checkPoint)
We can supply some arbitrary arrays of checkpoints and check the output or instead, we can think about some properties that our function should satisfy. Here are these properties expressed in code.
open FsCheck.Xunit
open RouteModels
module RemoveRedundantCheckpointsTests =
let ``result array contains no more than 5 items`` input mapFn =
let res = mapFn input
Array.length res <= 5
[<Property>]
let maxLength x =
``result array contains no more than 5 items`` x removeRedundantCheckpoints
let ``result contains first point from input``
(input: Location[]) (mapFn : Location[] -> Location[]) =
if Array.length input = 0 then
true
else
let res = mapFn input
res.[0] = input.[0]
[<Property>]
let firstItem x =
``result contains first point from input`` x removeRedundantCheckpoints
let ``result contains last point from input``
(input: Location[]) (mapFn : Location[] -> Location[]) =
if Array.length input = 0 then
true
else
let res = mapFn input
res.[res.Length-1] = input.[input.Length-1]
[<Property>]
let lastItem x =
``result contains last point from input`` x removeRedundantCheckpoints
let ``result contains only points from input`` input mapFn =
let res = mapFn input
Array.length (Array.except input res) = 0
[<Property>]
let onlyInput x =
``result contains only points from input`` x removeRedundantCheckpoints
As you can see from the imports statement, we’re relying on FsCheck to generate some random values for us.
Later in the code, we declare a higher-order function that accepts the mapper function and input array and returns a boolean condition that checks whether the property is satisfied. Double backticks is a convenient F# feature that allows us to express property in a natural language.
The test is decorated with Property
attribute and accepts input generated by FsCheck
as well as removeRedundantCheckpoints
function which is subject to change. With such a setup, we can check whether the function under tests satisfies provided properties with the multitude of random values generated by a library.
Conclusion
When it comes to testing, a lot of teams really put the same effort into designing a test suite as into the application code. And even those who do rarely consider something outside of the traditional testing pyramid. Still, property-based testing represents a nice option for pure logic that usually resides in the domain layer or in the mapping layers of your application.
History
- 6th June, 2023: Initial version