Sunday, December 27, 2020

PowerShell: Unit Tests Compare Doubles for Equality

Comparing double and single values (floating point numbers) can lead to rounding errors. The way to get around this is to define a tolerance meaning just how much of a difference is acceptable when comparing two floating point numbers. To demonstrate how compare floating point values using PowerShell the PsUnit class will be extended to support an AreEqual method with the following signature:

class PsUnit {
    hidden static [void] AreEqual(
                            [double] $leftValue, 
                            [double] $rightValue, 
                            [double] $tolerance,
                            [string] $filename, 
                            [int] $lineNumber) 
}

The PsUnit class was introduced in the post PowerShell: Unit Test Functions and Accessing the Invoking Code's Line Number. Recall from the aforementioned post that functions were used to wrap PsUnit methods in order to pass in the invoking code's filename and line number. The function to invoke the override of AreEqual that compares values of type double is AreEqualDouble:

function AreEqualDouble  {
    Param(
        [double] $leftValue,
        [double] $rightValue,
        [double] $tolerance                     
    )

    [PsUnit]::AreEqual(
        $leftValue, 
        $rightValue, 
        $tolerance,
        $MyInvocation.ScriptName, 
        $MyInvocation.ScriptLineNumber)
}

The PsUnit class's methods that compares doubles or supports the comparing of doubles are defined as follows:

    hidden static [void] AreEqualCoreValidations(
                            [object] $leftValue, 
                            [object] $rightValue, 
                            [string] $filename, 
                            [int] $lineNumber) {    
        if (($null -eq $leftValue) -and ($null -eq $rightValue)) {
            return 
        } 

        if ($null -eq $rightValue) {
            throw [PsUnit]::CreateMessage(
                "Test failed, assigned left value ($leftValue) cannot compared to right value of null",
                $filename,
                $lineNumber)
        } 
        
        if ($null -eq $leftValue) {
            throw [PsUnit]::CreateMessage(
                "Test failed, assigned right value ($rightValue) cannot compared to left value of null",
                $filename,
                $lineNumber)
        } 

        if ($leftValue.GetType() -ne $rightValue.GetType()) {
            throw [PsUnit]::CreateMessage(        
                "Test failed type differ $($leftValue.GetType()) -ne $($rightValue.GetType())",
                $filename,
                $lineNumber)
        }                        
    }

    hidden static [void] AreEqual(
                            [double] $leftValue, 
                            [double] $rightValue, 
                            [double] $tolerance,                     
                            [string] $filename, 
                            [int] $lineNumber) {
        [PsUnit]::AreEqualCoreValidations(
                    $leftValue, 
                    $rightValue, 
                    $filename, 
                    $lineNumber)
        if ([System.Math]::Abs($leftValue - $rightValue) -le 
            $tolerance) {
            return 
        }

        throw [PsUnit]::CreateMessage(        
            "Test failed: difference in values ($([System.Math]::Abs($leftValue - $rightValue))) exceeds tolerance ($tolerance)",
            $filename,
            $lineNumber)
    }

The PsUnit class code, the wrapper functions, and the test functions are shown below in their entirety. All code related to the comparing of doubles is demarcated by boldface:

Set-StrictMode -Version 3.0

class PsUnit {
    hidden static [string] $filenameLineNumberSeparator

    hidden static [string] $filenameLineNumberStart

    hidden static [string] $filenameLineNumberEnd

    hidden static [string] $boolMismatchMessage
    
    static PsUnit() {
        [PsUnit]::filenameLineNumberStart = '('
        [PsUnit]::filenameLineNumberEnd = ')'
        [PsUnit]::filenameLineNumberSeparator = ':'
        [PsUnit]::boolMismatchMessage = 'Test expected value of '
    }

    hidden static [string] CreateMessage(
                                [string] $message, 
                                [string] $filename, 
                                [int] $lineNumber) {
        return "$message $([PsUnit]::filenameLineNumberStart)$filename" + 
            "$([PsUnit]::filenameLineNumberSeparator)$lineNumber$([PsUnit]::filenameLineNumberEnd)"
    }


    hidden static [void] IsTrue(
                            [bool] $shouldBeTrue, 
                            [string] $filename, 
                            [int] $lineNumber) {
        if ($shouldBeTrue) {
            return 
        }

        throw [PsUnit]::CreateMessage(
                    "$([PsUnit]::boolMismatchMessage) $true",
                    $filename,
                    $lineNumber)
    }

    hidden static [void] IsFalse(
                            [bool] $shouldBeFalse, 
                            [string] $filename, 
                            [int] $lineNumber) {
        if (!($shouldBeFalse)) {
            return 
        }

        throw [PsUnit]::CreateMessage(
                    "$([PsUnit]::boolMismatchMessage) $false",
                    $filename,
                    $lineNumber)
    }

    hidden static [void] AreEqualCoreValidations(
                            [object] $leftValue, 
                            [object] $rightValue, 
                            [string] $filename, 
                            [int] $lineNumber) {    
        if (($null -eq $leftValue) -and ($null -eq $rightValue)) {
            return 
        } 

        if ($null -eq $rightValue) {
            throw [PsUnit]::CreateMessage(
                "Test failed, assigned left value ($leftValue) cannot compared to right value of null",
                $filename,
                $lineNumber)
        } 
        
        if ($null -eq $leftValue) {
            throw [PsUnit]::CreateMessage(
                "Test failed, assigned right value ($rightValue) cannot compared to left value of null",
                $filename,
                $lineNumber)
        } 

        if ($leftValue.GetType() -ne $rightValue.GetType()) {
            throw [PsUnit]::CreateMessage(        
                "Test failed type differ $($leftValue.GetType()) -ne $($rightValue.GetType())",
                $filename,
                $lineNumber)
        }                        
    }

    hidden static [void] AreEqual(
                            [double] $leftValue, 
                            [double] $rightValue, 
                            [double] $tolerance,
                            [string] $filename, 
                            [int] $lineNumber) {
        [PsUnit]::AreEqualCoreValidations(
                        $leftValue, 
                        $rightValue, 
                        $filename, 
                        $lineNumber)
        if ([System.Math]::Abs($leftValue - $rightValue) -le $tolerance) {
            return 
        }

        throw [PsUnit]::CreateMessage(        
            "Test failed: difference in values ($([System.Math]::Abs($leftValue - $rightValue))) exceeds tolerance ($tolerance)",
            $filename,
            $lineNumber)
    }

    hidden static [void] AreEqual(
                            [object] $leftValue, 
                            [object] $rightValue, 
                            [string] $filename, 
                            [int] $lineNumber) {    
        [PsUnit]::AreEqualCoreValidations(
                        $leftValue, 
                        $rightValue, 
                        $filename, 
                        $lineNumber)
        if ($leftValue -eq $rightValue) {
            return
        }

        throw [PsUnit]::CreateMessage(        
            "Test failed: values not equal $leftValue -ne $rightValue",
            $filename,
            $lineNumber)
    }        
}

function IsTrue  {
    Param(
        [Parameter(Mandatory=$true)]
        [bool] $shouldBeTrue     
    )
    
    [PsUnit]::IsTrue(
            $shouldBeTrue, 
            $MyInvocation.ScriptName, 
            $MyInvocation.ScriptLineNumber)
}

function IsFalse  {
    Param(
        [Parameter(Mandatory=$true)]
        [bool] $shouldBeFalse     
    )
     
    [PsUnit]::IsFalse(
            $shouldBeFalse, 
            $MyInvocation.ScriptName, 
            $MyInvocation.ScriptLineNumber)
}

function AreEqual  {
    Param(
        [object] $leftValue,
        [object] $rightValue             
    )

    [PsUnit]::AreEqual(
        $leftValue, 
        $rightValue, 
        $MyInvocation.ScriptName, 
        $MyInvocation.ScriptLineNumber)
}

function AreEqualDouble  {
    Param(
        [double] $leftValue,
        [double] $rightValue,
        [double] $tolerance                     
    )

    [PsUnit]::AreEqual(
        $leftValue, 
        $rightValue, 
        $tolerance,
        $MyInvocation.ScriptName, 
        $MyInvocation.ScriptLineNumber)
}

function Test-AreEqualUnequal {
    Param(
        [object] $leftValue,   
        [object] $rightValue                                  
    )    

    try {
        AreEqual $leftValue $rightValue
    }
    
    catch {
        return 
    }        

    throw "Exception should have been thrown ($($MyInvocation.ScriptName):$($MyInvocation.ScriptLineNumber))"
}

function Test-AreEqual {
    try {
        AreEqual $false $false
        AreEqual $true $true
        AreEqual $null $null
        AreEqual 10 10
        AreEqual 'xyZ' 'XYZ'
    }
    
    catch {
        throw
    }    

    Test-AreEqualUnequal 'xyZ' $null
    Test-AreEqualUnequal $null 'xyZ'
    Test-AreEqualUnequal 'qed' $null
    Test-AreEqualUnequal $true $false
    Test-AreEqualUnequal $false $true
    Test-AreEqualUnequal 10 5
    Test-AreEqualUnequal 'x' 5
}

function Test-AreEqualUnequalDouble {
    Param(
        [double] $leftValue,   
        [double] $rightValue,
        [double] $tolerance                                          
    )    

    try {
        AreEqualDouble $leftValue $rightValue $tolerance 
    }
    
    catch {
        return 
    }        

    throw "Exception should have been thrown ($($MyInvocation.ScriptName):$($MyInvocation.ScriptLineNumber))"
}

function Test-AreEqualDouble {
    [double] $value01 = 0.1
    [double] $value02 = 0.11
    [double] $tolerance = 0.015

    try {
        AreEqualDouble $value01 $value02 $tolerance
        $value02 = 0.09
        AreEqualDouble $value01 $value02 $tolerance
        $value01 = 100.0
        $value02 = 110.0
        $tolerance = 10.001
        AreEqualDouble $value01 $value02 $tolerance
        $value01 = -100.0
        $value02 = -110.0
        $tolerance = 10.001
        AreEqualDouble $value01 $value02 $tolerance
        $value01 = 0.01
        $value02 = 0.01
        $tolerance = 0
        AreEqualDouble $value01 $value02 $tolerance    
    }
    
    catch {
        throw
    }    

    $value01 = 100.0
    $value02 = 110.0
    $tolerance = 9.999
    Test-AreEqualUnequal $value01 $value02 $tolerance
    $value01 = -100.0
    $value02 = -110.0
    Test-AreEqualUnequal $value01 $value02 $tolerance
    $value01 = 0.01
    $value02 = 0.0101
    $tolerance = 00009
    Test-AreEqualUnequal $value01 $value02 $tolerance
    Test-AreEqualUnequal $value01 $value02 $tolerance
    $value01 = -0.01
    $value02 = -0.0101
    Test-AreEqualUnequal $value01 $value02 $tolerance
}

function Test-IsFalse {
    [bool] $expectedException = $false

    try {
        IsFalse $false
        IsFalse $($null -eq '')
        $expectedException = $true
        IsFalse $true
    }
    
    catch {
        if ($expectedException) {
            return
        }

        throw
    }    
}

function Test-IsTrue {
    [bool] $expectedException = $false

    try {
        IsTrue $true
        IsTrue $('AbC' -eq 'aBC')
        $expectedException = $true
        IsTrue $false
    }
    
    catch {
        if ($expectedException) {
            return
        }

        throw
    }    
}

try {
    Test-AreEqual
    Test-AreEqualDouble    
    Test-IsFalse
    Test-IsTrue
    Write-Host 'Success'
}

catch {
    $errorRecord = $_    
    Write-Host "Error: $errorRecord.Exception"
    Write-Host 'Failure'
}





No comments :

Post a Comment