Thursday, December 31, 2020

PowerShell: Handling/Logging Exceptions

This post introduces a class, ErrorRecordExtension, that handles PowerShell exceptions and supports the logging of said exceptions. All source code is provided at the end of the post in Appendix A. The ErrorRecordExtension class provides methods for cleaning  up the formatting of the information provided when an exception is handled. The constructor for the ErrorRecordExtension class is defined at line 16 in the code snippet below:


The first parameter to the ErrorRecordExtension constructor is of type ErrorRecord. When an exception is caught in PowerShell, the pipeline variable ($_ or $PSItem) is a value of type ErrorRecord describing the exception. The class name, ErrorRecordExtension, was chosen paying homage to C# extensions which allow methods to be added to existing classes (extension methods).

The $exceptionHandledAtLineNumber parameter corresponds to the caller's $MyInvocation's ScriptLineNumber property. The $exceptionHandledAtFilename parameter corresponds to the caller's $MyInvocation's ScriptName property. 

The code to generate a test exception and more elegantly handle the exception using the class, ErrorRecordExtension, is as follows:


The exception is thrown is a caused by a divide-by-zero operation being performed at line 91. The exception is caught and an instance of ErrorRecordExtension is constructed immediately (lines 100-104). The pipeline variable ($_) should be used/assigned right away as its value could be changed depending on the context in which is it is used. The methods exposed by the ErrorRecordExtension are demonstrated at line 106 to line 112.

GetException

The ErrorRecord class contains an Exception property of type System.Exception. One purpose of the ErrorRecordExtension is to facilitate logging so it remans to be seen if invoking the ToString method of the ErrorRecord's Exception property generates a suitable message for logging. The Exception property's ToString method displays text as follows:


The above text includes the Exception class's Message and StackeTrace property. The StackTrace when displayed as text spans multiple lines. This is acceptable for Xml or Json based logging. When logging to CSV/TSV file a single line error message simplifies that output. The following is an example of the text generated by invoking the GetException method of the ErrorRecordExtension  class:


The GetException method is overloaded with a publicly visible method implemented at line 49 and a hidden implemented (method overloading) at line 34 (see below). The version of GetException decorated by the hidden keyword is designed to be used recursively:


The Flatten method (line 26 above) is used to remove the carriage return and linefeed characters from strings such as Exception.StackTrace. The GetStackTrace method invokes Flatten and then use the -replace operator to finalize formatting:

[ErrorRecordExtension]::Flatten($exception.StackTrace) -replace '\s+', ' '

The operation "-replace '\s+', ' '" replaces any multiple sequences of spaces with a single space. The GetException method at line 34 creates a string from the Message, TargetSite and StrackTrace of an Exception (lines 35-380). The text associated with the InnerException is appended to the message using recursion (see lines 41-43).

GetInvocationPosition

The ErrorRecord's InvocationInfo property exposes the PositionMessage property. An example of the output from PositionMessage is as follows:

+     return $n / $d
+            ~~~~~~~

The PositionMessage property spans multiple lines. The GetInvocationPosition method of the ErrorRecordExtension class converts the PositionMessage multiline text into a single line representation:

At C:\Blog\Diff\ErrorRecordExtension.ps1:43 char:12     return $n / $d

The GetInvocationPosition method is implemented as follows (see line 30):


The GetInvocationPosition method removes the carriage return and linefeed characters from the PositionMessage property using the Flatten method. The replace method of the string class removes unnecessary characters (see code demarcated in boldface below):

    [string] GetInvocationPosition() {
        return [ErrorRecordExtension]::Flatten(
            $this.errorRecord.InvocationInfo.PositionMessage).
                Replace('~', '').Replace('+', '')
    }

GetInvocationLine, GetInvocationScriptLineNumber, GetInvocationScriptName

The output from the GetInvocationLine, GetInvocationScriptLineNumber, and GetInvocationScriptName methods is as follows (each line is a the output from a different method):

    return $n / $d
59
ErrorRecordExtension.ps1

The aforementioned three methods are implemented as follows (line 40, line 45, and line 49):


The GetFilename method is defined at line 12 above. This method returns the filename from the path specified by the parameter, $fullPath.

GetHandledAtLineNumber/GetHandledAtScriptName

The output from the GetHandledAtLineNumber and GetHandledAtScriptName methods is as follows:

122
ErrorRecordExtension.ps1 

The implementation of these methods is as follows (see line 40 and line 44):

GetErrorRecord

The GetErrorRecord method of the ErrorRecordExtension class returns the ErrorRecord passed to the class' constructor:

Appendix A: Code in its Entirety

Set-StrictMode -Version 3.0

class ErrorRecordExtension {
    hidden static [string] $messageSeparator

    hidden [System.Management.Automation.ErrorRecord] $errorRecord

    hidden [int] $exceptionHandledAtLineNumber 

    hidden [string] $exceptionHandledAtFilename 

    static ErrorRecordExtension() {
        [ErrorRecordExtension]::messageSeparator = ':'
    }
    
    ErrorRecordExtension(
        [System.Management.Automation.ErrorRecord] $errorRecord,        
        [int] $exceptionHandledAtLineNumber,
        [string] $exceptionHandledAtFilename) {

        $this.exceptionHandledAtLineNumber = 
            $exceptionHandledAtLineNumber
        $this.exceptionHandledAtFilename = 
            $exceptionHandledAtFilename            
        $this.errorRecord = $errorRecord
    }

    hidden static [string] Flatten([string] $text) {
        return $text.Replace("`n", '').Replace("`r", '')
    }

    hidden static [string] GetFilename([string] $fullPath) {
        if ([string]::IsNullOrEmpty($fullPath)) {
            return ''
        }

        return Split-Path $fullPath -Leaf
    }

    [int] GetHandledAtLineNumber() {
        return $this.exceptionHandledAtLineNumber
    }

    [string] GetHandledAtScriptName() {
        return [ErrorRecordExtension]::GetFilename(
                    $this.exceptionHandledAtFilename)
    }

    [string] GetInvocationLine() {
        return [ErrorRecordExtension]::Flatten(
                    $this.errorRecord.InvocationInfo.Line)
    }

    [int] GetInvocationScriptLineNumber() {
        return $this.errorRecord.InvocationInfo.ScriptLineNumber
    }

    [string] GetInvocationScriptName() {
        return [ErrorRecordExtension]::GetFilename(
                $this.errorRecord.InvocationInfo.ScriptName)
    }

    [string] GetInvocationPosition() {
        return [ErrorRecordExtension]::Flatten(
            $this.errorRecord.InvocationInfo.PositionMessage).
                Replace('~', '').Replace('+', '')
    }

    hidden static [string] GetStackTrace([Exception] $exception) {
        return [ErrorRecordExtension]::Flatten(
                    $exception.StackTrace) -replace '\s+', ' '
    }

    hidden static [string] GetException([Exception] $exception) {
        [string] $message =             "$($exception.Message)$([ErrorRecordExtension]::messageSeparator)" +
"$($exception.TargetSite)$([ErrorRecordExtension]::messageSeparator)" + 
            "$([ErrorRecordExtension]::GetStackTrace($exception))"

        if ($null -ne $exception.InnerException) {
            $message += 
                [ErrorRecordExtension]::messageSeparator +
                [ErrorRecordExtension]::GetException(
                              $exception.InnerException)
        }

        return $message
    }

    [string] GetException() {
        return [ErrorRecordExtension]::GetException(
                    $this.errorRecord.Exception)
    }

    [System.Management.Automation.ErrorRecord] GetErrorRecord() {
        return $this.errorRecord
    }
}

function New-DivideByZeroException {
    [int] $d = 0
    [int] $n = 10

    return $n / $d
}

function New-ActionToShowOffExceptoinHandler {
    try {
        New-DivideByZeroException
    }
    
    catch {
        [ErrorRecordExtension] $errorRecordExtension = 
                    [ErrorRecordExtension]::new(
                            $_, 
                            $MyInvocation.ScriptLineNumber, 
                            $MyInvocation.ScriptName)
    
        Write-Host $errorRecordExtension.GetHandledAtLineNumber()
        Write-Host $errorRecordExtension.GetHandledAtScriptName()
        Write-Host $errorRecordExtension.GetInvocationLine()
        Write-Host `
          $errorRecordExtension.GetInvocationScriptLineNumber()
        Write-Host $errorRecordExtension.GetInvocationScriptName()                                
        Write-Host $errorRecordExtension.GetInvocationPosition()
        Write-Host $errorRecordExtension.GetException()
    }
}

New-ActionToShowOffExceptoinHandler

PowerShell: Class Properties Returning Collections (ReadOnlyCollection)

When implementing a class or any code construct, it is critical to avoid side effects. One cause of side effects is when mutable data is provided as a parameter to a method, constructor or function. Additionally side effects can be caused when mutable data is assigned to a property.  To demonstrate host to avoid side effects, consider the following the Friend class:

The first parameter to the constructor defined at line 8 is $name a string type. The string type is immutable. The second parameter is a string array named $tag and it should be recognized that the array type is mutable. Notice at line 10 the following code is invoked making a clone of the $tag string array:

$tag.Clone()

The array created by $tag.Clone() is used to create a value of type of ReadOnlyCollection. The ReadOnlyCollection type cannot be modified hence it is immutable. 

When the GetTags method is invoked (line 17) the value returned therefore cannot be modified by the caller and as the [ReadOnlyCollection[string]] is read-only. The following code demonstrates that the value return by the GetTag method of the Friend class is not subject to side effects:


Line 30 of the code above changes the array passed as a parameter to the constructor of the Friend class. The invoking of $tag.Clone at line means that any change to the array declared at line 26 does not affect $this.tag property of the Friend class. The output from lines 29 and 31 is as follows showing the Friend class instance has not been modified:


Not Invoking the Clone Method

Removing the invocation of $tag.Clone() from line 10 below (see below) makes the instance of the Friend class susceptible to side effects:


The value returned by the GetTags method is of type ReadOnlyCollection but this type is just a read-only wrapper for the underlying string array. Modifying the string array outside of the Friend instance will change the value returned by the GetTags method.

Demonstrating this, the code at line 30 below modifies the tags of the Friend instance, a side effect:


The output from the above code is follows:


The output displayed at line 29 and line 31 differs because line 30 was able to modify the value assigned to the instance of the Friend class by changing the tag "friendly" to "mean".

Not using ReadOnlyCollection

Changing the data type of the $tag property of the Friend class from ReadOnlyCollection to string array also allows side effects. The aforementioned change to the code is shown below and note that line 10 does invoke $tag.Clone():



In the code above, line 6 shows the property $this.tags is now of type [string[]]. The return value of the GetTags method (line 17) is also of type [string[]]. When the code below is invoked, line 31 is able to change the data in the Friend class' $this.tags property:


The output from the code above is as follows:



The changing of the tag from the word, "kind", to the word, "calm" shows that the Friend instance's $this.tags property has been modified. Had the value return by the GetTags method been of type ReadOnlyCollection it would not be possible to modify this return value as there is no method or property exposed by ReadOnlyCollection that allows and instance of this object type to be modified as it is immutable. 

Conclusion

The discussion in this post focused on the properties of classes and the parameters passed to constructors. The same issues with side effects be experienced by the parameters passed to functions or the parameters passed to class methods.

Correct Implementation

# Import-Module '.\PsUnit.psm1'

Set-StrictMode -Version 3.0

class Friend {
    hidden [string] $name 

    hidden 
        [System.Collections.ObjectModel.ReadOnlyCollection[string]] 
                    $tags

    Friend([string] $name, [string[]] $tags) {
        $this.name = $name
        $this.tags =        [System.Collections.ObjectModel.ReadOnlyCollection[
                            string]]::new(
                                $tags.Clone())
    }

    [string] GetName() {
        return $this.name
    }

    [System.Collections.ObjectModel.ReadOnlyCollection[string]] 
                                                    GetTags() {
        return $this.tags
    }

    [string] ToString() {
        return "Name: $($this.name), Tags: $($this.tags -join ':')"
    }
}

[string[]] $tags = @('friendly', 'kind', 'brave')
[Friend] $friend = [Friend]::new('Joe', $tags)

$friend.ToString()
$tags[0] = 'mean'
$friend.ToString()

Wednesday, December 30, 2020

PowerShell: Working with UTC Date/Time Values, String Representations and Parsing

The previous post on PowerShell enumerations (see PowerShell: Enumerations) actually demonstrated PowerShell's support for UTC date/time, how to format UTC dates as text, and how to covert a string to UTC time. The sample code in the aforementioned post created a log entry class that uses UTC time versus local time (see line 25):

In the code above the Get-Date cmdlet retrieves the current/date time and the DateTime structure's ToUniversalTime method converts the local time to UTC.

When a log entry (class SimpleLogEntry) instance is written as a line of text so it can be logged, the code to perform this task is as follows (the ToString method of the SimpleLogEntry class):


The GetDateTmeText method above converts the date/time associated with log entry to text using:

$this.dateTime.ToString('o')

The 'o' is one of .NET standard date/time formatters (see Standard date and time format strings). As the documentation from Microsoft below states, the 'o' format produces a string but can be parsed back to the original date:


The code that parses the date/time in string format in order to construct a new instance of SimpleLogEntry is as follows:


The code at line 41 first casts the string to a DateTime structure which creates a date/time object corresponding to local time:

[DateTime]$items[[LogItemIndex]::Date]

Invoking the ToUniversalTime method converts the local time to UTC:

([DateTime]$items[[LogItemIndex]::Date]).ToUniversalTime()

With that the round trip is complete from UTC date/time to string and back to UTC date/time.





PowerShell: Overriding Methods

The introduction of classes in PowerShell 5.1 gave PowerShell the ability to override methods. Every class in PowerShell is derived from the System.Object base class. The System.Object base class exposes the ToString method which is a virtual method meaning it can be overridden  The previous blog post, PowerShell: Enumerations, contained an example of overriding the ToString method (see line 32):


The SimpleLogEntry class shown above is derived by default from the common base class, System.Common. C# developers will note that there is no use of explicate override keyword required in order to override ToString in the SimpleLogEntry class.

One cmdlet that takes invokes the ToString methods is Write-Host. An excerpt from the documentation for Write-Host (see Write-Host) is as follows showing that the cmdlet invokes ToString for the object passed in as a parameter (the object to be written to output in string form):


The following code (shown in the aforementioned blog post) passed an instance of type, SimpleLogEntry, to the Write-Host cmdlet (line 102):


Invoking Write-Host at line 102 takes in the parameter, $logEntry. Write-Host invokes ToString. The output from two invocations of the Write-Log function (which contains the call to Write-Host) is as follows (two invocations of ToString):




Tuesday, December 29, 2020

PowerShell: Passing Enumeration Values as Function Parameters

In a previous post PowerShell enumerations were introduced  (see PowerShell: Enumerations). One topic related to enumerations merits additional discussion, namely passing enumeration values to functions. In brief, when passing enumeration values to a function, the enumeration values must be surrounded by parentheses. In the example below line 20 (enumeration value wrapped in parentheses) succeeds when invoked while line 21 ((enumeration value not wrapped in parentheses) generates an exception:


The example of passing enumerations as function parameters in its entirety is as follows where line 20 passes an enumeration value bracketed by parentheses and line 21 passes an enumeration value sans parentheses: 


Invoking line 20 (the enumeration passed with parentheses) displays the following output:

Log Level: Info (2)

Invoking line 21 (the enumeration passed without parentheses) generates an exception which is handled at line 25 (see code above). The first line of text displayed by line 27 is:

System.Management.Automation.ParameterBindingArgumentTransformationException: Cannot process argument transformation on parameter 'logLevel'. Cannot convert value "[LogLevel]::Info" 
to type "LogLevel". Error: "Unable to match the identifier name [LogLevel]::Info to a valid enumerator name. Specify one of the following enumerator names and try again:
Error, Warn, Info"

The error shown by line 28 is follows:


The correct way to pass enumeration values to functions in PowerShell should be clear: parentheses, parentheses, parentheses!

Appendix A: Code from Post

Set-StrictMode -Version 3.0

enum LogLevel {
    Error
    Warn
    Info
}

function Test-PassngEnumAsParameter {
    param
    (
        [Parameter(Mandatory=$true)]
        [LogLevel] $logLevel
    )

    Write-Host "Log Level: $logLevel ($([int]$logLevel))"
}

try {
    Test-PassngEnumAsParameter ([LogLevel]::Info)
    Test-PassngEnumAsParameter [LogLevel]::Info
    Write-Host 'Success'
}

catch {
    $errorRecord = $_    
    Write-Host $errorRecord.Exception
    Write-Host $errorRecord[0].InvocationInfo.PositionMessage
    Write-Host 'Error'
}







PowerShell: Enumerations

PowerShell 5.1 introduced classes (which were partially reviewed in post PowerShell: Classes Static Constructors, Static Methods, and Static Properties) and enumerations. Microsoft's documentation on enumeration describes them as (see About Enum):


An example of a too commonly defined (regardless of language) enumeration is as follows where the enumeration values are Error, Warn, and Info:


When displayed, by default, an enumeration value is displayed as text but each enumeration value is associated with a numeric value (Error = 0, Warn = 1, and Info = 2). The following script snippet demonstrates this:


The output from the above snippet is as follows:


An example of an enumeration (LogLevel) used in a script is the following code which implements the SimpleLogEntry class:


The LogLevel enumeration is a property of the class declared and line 18 and is accessible as the return value of the GetLogLevel method declared at line 32.

Enumerations can be compared using standard numeric operators such as -eq, -ne, -le, -lt, -ge, and -gt. An example of comparing two enumerations is line 98 below which limits the logs displayed to being Error only or Error/Warning or Error/Warn/Info (a.k.a. the current log level):


The enumerations numeric values can also be used as array index. Consider the following enumeration also used the SimpleLogEntry class:


The code which converts a log entry to a line of text is as follows where each property of the SimpleLogEntry class is separated by a delimiter (a tab character) when converted to a string using the ToString method:


The LogItemIndex enumeration is used in a constructor to access elements within an array at lines 28, 29, and 31 allowing an instance of SimpleLogEntry to be created from its string representation:


Notice in the code snippet above that a text value was converted to an instance of LogLevel at lines 25-26 using PowerShell's explicit cast operator:
    $this.logLevel = [LogLevel]$items[[LogItemIndex]::LogLevel]

The test function for the SimpleLogEntry class is implemented as follows:
  1. Create an instance of SimpleLogEntry using the constructor that takes a LogLevel enumeration and a message parameter (lines 77-78)
  2. Get the string representation of the SimpleLogEntry using the ToString method (line line 79)
  3. Creates a second instance of SimpleLogEntry using the constructor that takes a string (a line of text created from ToString) as a parameter (lines 80-81)
  4. Get the string representation of the second SimpleLogEntry instance from ToString (line 82)
  5. Compare the string representation of both SimpleLogEntry instances and if they are equal the test was successful (lines 84-86)
  6. If the two instance of SimpleLogEntry do not match, throw an exception (line 88)

Appendix A: Script in its Entirety

Set-StrictMode -Version 3.0

enum LogLevel {
    Error
    Warn
    Info
}

enum LogItemIndex {
    Date
    LogLevel
    Message
}

class SimpleLogEntry {
    hidden static [string] $delimiter

    hidden [DateTime]  $dateTime

    hidden [LogLevel]  $logLevel

    hidden [string]  $message

    static SimpleLogEntry() {
        [SimpleLogEntry]::delimiter = "`t"
    }

    SimpleLogEntry([LogLevel] $logLevel, [string] $message) {
        $this.logLevel = $logLevel
        $this.message = $message
        $this.dateTime = (Get-Date).ToUniversalTime()
    }

    SimpleLogEntry([string] $logLine) {
        [string[]] $items = 
          $logLine -split [SimpleLogEntry]::delimiter

        $this.logLevel = [LogLevel]$items[[LogItemIndex]::LogLevel]
        $this.message = $items[[LogItemIndex]::Message]
        $this.dateTime = 
          ([DateTime]$items[[LogItemIndex]::Date]).ToUniversalTime()
    }    

    [DateTime] GetDateTime() {
        return $this.dateTime
    }

    [string] GetDateTimeText() {
        return $this.dateTime.ToString('o')
    }

    [LogLevel] GetLogLevel() {
        return $this.logLevel
    }

    [string] GetMessage() {
        return $this.message            
    }

    [string] ToString() {
        return `
       "$($this.GetDateTimeText())$([SimpleLogEntry]::delimiter)" + 
            "$($this.logLevel)$([SimpleLogEntry]::delimiter)" + 
            "$($this.message)"
    }
}

function Test-SimpleLogEntry {
    param
    (
        [Parameter(Mandatory=$true)]
        [LogLevel] $logLevel,
        [Parameter(Mandatory=$true)]
        [string] $message
    )
    
    [SimpleLogEntry] $logEntry = 
           [SimpleLogEntry]::new($logLevel, $message)
    [string] $line = $logEntry.ToString()
    [SimpleLogEntry] $logEntryFromParse = 
                        [SimpleLogEntry]::new($line)
    [string] $lineFromParse = $logEntryFromParse.ToString()

    if ($lineFromParse -eq $line) {
        return $logEntry
    }

    throw "Log entries differ '$lineFromParse' -ne '$line'"

function Write-Log {
    param
    (
        [Parameter(Mandatory=$true)]
        [SimpleLogEntry] $logEntry
    )

    if ($logEntry.GetLogLevel() -gt $globalLogLevel) {
        return 
    }

    Write-Host $logEntry
}

[LogLevel] $globalLogLevel = [LogLevel]::Warn

try {
    [SimpleLogEntry] $logEntry = 
        Test-SimpleLogEntry `
            ([LogLevel]::Error) 'A message to log'

    Write-Log $logEntry
    $logEntry = Test-SimpleLogEntry `
                  ([LogLevel]::Warn) 'Any log message'
    Write-Log $logEntry
    $logEntry = Test-SimpleLogEntry `
                  ([LogLevel]::Info) 'Another log message'
    Write-Log $logEntry

    Write-Host 'Success'
}

catch {
    $errorRecord = $_    
    Write-Host $errorRecord.Exception
    Write-Host $errorRecord[0].InvocationInfo.PositionMessage
    Write-Host 'Error'
}

Monday, December 28, 2020

PowerShell: Classes Static Constructors, Static Methods, and Static Properties

The previous two posts presented a practical implementation of a static class, PsUnit, in PowerShell (PowerShell: Unit Test Functions and Accessing the Invoking Code's Line Number and PowerShell: Unit Tests Compare Doubles for Equality). A static class does not need to be instantiated in order to invoke its properties and methods.  In .NET one of the most commonly used static classes is the System.Math class that exposes fields such as (shown in their PowerShell form):

[System.Math]::E
[System.Math]::PI
[System.Math]::Tau

The methods of System.Math class include the following:

[System.Math]::Abs
[System.Math]::Sin
[System.Math]::Cos
[System.Math]::Tan
[System.Math]::Log
[System.Math]::Pow

In PowerShell classes can contain one static constructor that takes no parameters. The static constructor is invoked when the class is first accessed within a script. The first time a method or property of class is invoked, the static constructor is first invoked.

The static constructor for the PsUnit class (introduced in the previous two posts) is as follows on line 12 and is decorated by the keyword static and takes no parameters:


The static constructor shown above is used to initialize the following static properties declared at lines 4, 6, 8, and 10:
  • $filenameLineNumberStart
  • $filenameLineNumberEnd
  • $filenameLineNumberSeparator
  • $boolMismatchMessage
The static methods of the PsUnit class were invoked using the class name, [PsUnit], rather than creating an instance of type PsUnit. Line 36 below shows the IsTrue method of the PsUnit class (a static method) invoking the PsUnit class's static method, CreateMessage:



Line 159 below shows the AreEqual function invoking the AreEqual static method of the PsUnit class:


The code above shows one of the elegant features of PowerShell classes. The methods and properties of a PowerShell class do not pollute the global namespace.  The code [PsUnit]::AreEqual invokes a static method of the PsUnit class. The code below invokes the function AreEqual on lnes 200 to 204:









 

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'
}