nohwnd/Assert

Consider communicating intent via pipeline

Opened this issue · 0 comments

Should & Assert use pipeline to pass values to the assertions like this:

1,2,3 | Should -HaveType ([int])

This brings problems with enumerating collections if a collection is provided. It also makes it impossible to tell which type of collection was provided on the left side. For example this will fail because the collection gets unwrapped, and also we don't know which type of collection was used before the pipeline:

1,2,3 | Should -HaveType [object[]]

To solve this problem we can wrap the collection into an object artifically and make it pass the pipeline safely, by using the , array operator:

,(1,2,3) | Should -HaveType [object[]]

This is not very straight forward or readable, so instead a function could be introduced that would do this for us and would be used on the left side like this:

function Collection ($Collection) { 
    , $Collection 
}
Collection 1,2,3 | Should -HaveType [object[]]

Taking this idea forward we could use the same pattern to clarify the intent of what we want to assert on the collection, such as:

All -ItemsIn 1,2,3 | Should -Be 1
Any -ItemIn 1,2,3 | Should -Be 5

By making the function output an object that would describe the intent, capture the collection to pass it though the pipeline unwrapped, and offer a nice fluent syntax.

function All ($ItemsIn) {
    $collection = $($ItemsIn)
    $type = $collection.GetType()

    [PSCustomObject] @{
        PSTypeName = 'ShouldDescriptor'
        Action = 'All'
        Type = $type
        Content = $collection
    }
}

On the other side of the pipeline, Should would then recognise the object pstype and carry out the appropriate assertion. This would also possibly limit the parameter set boom that would inevitably come when extending Should with collection related assertions where options like -Any, -All, -InAnyOrder, -NoOtherItems etc. are useful and would need to be defined for every assertion.

Here is a simple prototype that implements the idea:

function Should 
{
    param (        
        [switch] $Be,
        $Expected,
        [Parameter(ValueFromPipeline)]
        $Actual
    )

    # we define what should happen when one item is compared
    if ($Be) {
        $predicate = { param($e, $a) $e -eq $a }
        $description = "be equal to"
        $message = "Actual {0} is not equal to {1}."
    }

    # we recognize that the wrapping function was used before the pipeline
   # and decide what to do based on the type of the function used
    if ( $null -ne $Actual -and $Actual.PSObject.TypeNames -eq 'ShouldDescriptor' ) {
        switch ($Actual.Action) {
            "All" { 
                Should-All -Actual $Actual.Content -Expected $Expected -Predicate $predicate -Description $description -Message $message
            }
            "Any" {
                Should-Any -Actual $Actual.Content -Expected $Expected -Predicate $predicate -Description $description -Message $message
            }
            Default {}
        }
    }
}

# this function wraps the collection in 
# a "should descriptor" object that Should recognizes
function All ($ItemsIn) {
    $collection = $($ItemsIn)
    $type = $null
    if ($null -ne $collection) {
        $type = $collection.GetType()
    }
    if ([object]::ReferenceEquals($ItemsIn, $collection)) 
    {
        $ofType = $null
        if ($null -ne $type) {
            $ofType = " of type $($type.Name)"
        }

        throw "Provided object$ofType is not a collection. 'All' can only be used on collections."
    }

    [PSCustomObject] @{
        PSTypeName = 'ShouldDescriptor'
        Action = 'All'
        Type = $type
        Content = $collection
    }
}

# this is another wrapper function this time for Any
function Any ($ItemIn) {
    $collection = $($ItemIn)
    $type = $null
    if ($null -ne $collection) {
        $type = $collection.GetType()
    }
    if ([object]::ReferenceEquals($ItemIn, $collection)) 
    {
        $ofType = $null
        if ($null -ne $type) {
            $ofType = " of type $($type.Name)"
        }

        throw "Provided object$ofType is not a collection. 'Any' can only be used on collections."
    }

    [PSCustomObject] @{
        PSTypeName = 'ShouldDescriptor'
        Action = 'Any'
        Type = $type
        Content = $collection
    }
}

# this will run when All was used and we ensure that all items in the collection
# passed the predicate
function Should-All ($Actual, $Expected, $Predicate, $Description, $Message) {
    
    $r = foreach ($a in $Actual) {
        if (-not (&$Predicate $Expected $a)) {
            $Message -f $a, $Expected
        }
    }

    if ($r) {
        throw "Expected all items in collection $Actual $Description $Expected, but:`n$([string]::Join("`t`n", $r))`n`n"
    }
}

# this will run when Any was used and we ensure that at least one item in
# the collection passed the predicate
function Should-Any ($Actual, $Expected, $Predicate, $Description, $Message) { 
    $oneFound = $false
    $r = foreach ($a in $Actual) {
        if (&$Predicate $Expected $a) {
            $oneFound = $true
        }
    }

    if (-not $oneFound) {
        throw "Expected at least one item in collection $([String]::join(', ', $Actual)) $Description $Expected, but no such things were found."
    }
}

# comment out the first assertion 
# and uncomment the second one to
# see the second one fail as well
All -ItemsIn 1,2,3 | Should -Be 1
# Any -ItemIn 1,2,3 | Should -Be 5
Expected all items in collection 1 2 3 be equal to 1, but:
Actual 2 is not equal to 1.
Actual 3 is not equal to 1.


At line:94 char:9
+         throw "Expected all items in collection $Actual $Description  ...
+         ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : OperationStopped: (Expected all it...t equal to 1.

:String) [], RuntimeException
    + FullyQualifiedErrorId : Expected all items in collection 1 2 3 be equal to 1, but:
Actual 2 is not equal to 1.
Actual 3 is not equal to 1.