Saturday, December 26, 2020

PowerShell: Unit Test Functions and Accessing the Invoking Code's Line Number

In a previous post it was shown that the methods of PowerShell classes cannot determine the invoking line number and filename using $MyInvocation.ScriptLineNumber and $MyInvocation.ScriptName respectively (see PowerShell: $Myinvocation.ScriptLineNumber behaves incorrectly with Class Methods). Certain categories of methods make use of the invoking line numbers and filenames such as unit test methods (AreEqual, IsTrue, IsFalse, etc.) and logging methods (LogError, LogWarning, LogInfo, etc.). This post will demonstrate how to make use of PowerShell classes and methods in conjunction with functions. The functions will utilize $MyInvocation.ScriptLineNumber and $MyInvocation.ScriptName and pass the line number and filenames to the class methods.

The class, PsUnit, is based off of well known unit test frameworks such as NUnit, XUnit, and JUnit but clear PsUnit is a trivial subset of a full unit test suite. The PsUnit class exposes methods IsTrue, IsFalse, and AreEqual which each take a filename and line number parameter:

class PsUnit {
   hidden static [void] IsTrue(
       [bool] $shouldBeTrue,
       [string] $filename,
       [int] $lineNumber)

   hidden static [void] IsFalse(
       [bool] $shouldBeFalse,
       [string] $filename,
       [int] $lineNumber)

   hidden static [void] AreEqual(
       [object] $leftValue,
       [object] $rightValue,
       [string] $filename,
       [int] $lineNumber)
}

The methods above are decorated with the hidden keyword. In PowerShell there is no private keyword like C++, Java, and C#. The hidden keyword means that features like IntelliSense do not detect methods prefixed by this keyword. The hidden keyword is as closest that PowerShell comes to the private keyword found in other languages. The long description of the keyword hidden is reviewed in Appendix A: The Hidden Keyword.

Each of the method signatures shown above is static, meaning that the method can be invoked without creating an instance of the PsUnit class. The following functions wrap these methods and pass in the filename and line numbers retrieved from $MyInvocation.ScriptName and $MyInvocation.ScriptLineNumber:

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

The PsUnit class's  methods IsTrue, IsFalse, and AreEqual all throw an exception if that value being tested contradicts the method name. The IsTrue method throws an exception if the value passed is $false. The AreEqual method throws an exception if the two values passed to the method are not equal.

The  PsUnit class is implemented in its entirety is as follows including code to test code AreEqual, IsFalse, and IsTrue:

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] AreEqual(
                           [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)
       }

       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 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-Is-False {
   [bool] $expectedException = $false

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

       throw
   }
}

function Test-Is-True {
   [bool] $expectedException = $false

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

       throw
   }
}

try {
   Test-Are-Equal
   Test-Is-False
   Test-Is-True
   Write-Host 'Success'
}

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

Appendix A: The Hidden Keyword

The documentation for the hidden keyword can be found at: about_Hidden. The long description of hidden keyword is defined in "about_Hidden" as follows:






 

No comments :

Post a Comment