Search code examples
unit-testingtestinglanguage-agnosticproperty-based-testing

property-based-test for simple object validation


consider this simple example:

  • Theres a Person object
  • It must have one of: FirstName and LastName (or both, but one is mandatory)
  • It must have a valid Age (integer, between 0 and 150)

How would you property-base-test this simple case?


Solution

  • I don't think you can meaningfully answer this question in a language-agnostic way, as the overall design approach will entirely depend on the capabilities of the language in question.

    For example, in languages with strong static types and sum types, most of the above requirements can be modelled declaratively using the type system. Here's an F# example:

    type Name =
    | FirstName of string
    | LastName of string
    | FullName of string * string
    

    This Name type can only contain either a first name, or a last name, or both. It's not possible to create values that don't follow the requirements.

    The case constructor of the following Age type can be hidden by putting the type in a separate module. If that module only exports the toAge (and getAge) function below, the only way to create an Age value would be to call toAge.

    type Age = Age of int
    
    let toAge x =
        if 0 <= x && x <= 150
        then Some (Age x)
        else None
    
    let getAge (Age x) = x
    

    Using these auxiliary types, you can now define a Person type:

    type Person = { Name : Name; Age : Age }
    

    Most of the requirements are embedded in the type system. You can't create an invalid value of the Person type.

    The only behaviour that can fail is contained in the toAge function, so that's the only behaviour that you can meaningfully subject to property-based testing. Here's an example using FsCheck:

    open System
    open FsCheck
    open FsCheck.Xunit
    open Swensen.Unquote
    
    [<Property(QuietOnSuccess = true)>]
    let ``Value in valid age range can be turned into Age value`` () =
        Prop.forAll (Gen.choose(0, 150) |> Arb.fromGen) (fun i ->
            let actual = toAge i
            test <@ actual |> Option.map getAge |> Option.exists ((=) i) @>)
    
    [<Property(QuietOnSuccess = true)>]
    let ``Value in invalid age range can't be turned into Age value`` () =
        let tooLow = Gen.choose(Int32.MinValue, -1)
        let tooHigh = Gen.choose(151, Int32.MaxValue)
        let invalid = Gen.oneof [tooLow; tooHigh] |> Arb.fromGen
        Prop.forAll invalid (fun i ->
    
            let actual = toAge i
    
            test <@ actual |> Option.isNone @>)
    

    As you can tell, it tests the two cases: valid input values, and invalid input values. It does this by defining generators for each of those cases, and then verifying the actual values.