Tuesday, July 6, 2021

PowerShell: Practical Pester Unit Tests

The previous post, PowerShell: Safely Converting Strings to by Value Types, contained code to test the conversion of strings to value types (int, bool, double, and DateTime). The PowerShell code is contained in file, Convert.ps1, so Convert.Tests.ps1 was added to include the Pester unit tests.

Pester is fully integrated with PowerShell and Visual Studio Code when the PowerShell Extension is installed. Right clicking on Convert.Tests.ps1 in Visual Studio Code's File Explorer displays a context menu which includes menu items Debug Pester Tests and Run Pester Tests:


The Pester code to test Covert.ps1 is as follows where each instance of the Pester Should command is highlighted in boldface:

Set-StrictMode -Version 3.0

Describe 'class Convert' {
  BeforeAll {
    . "$PSScriptRoot\Convert.ps1"
  }

  Context 'method GetDateTime' {
    BeforeAll {
      # O and o format as string in a manner that is parsible
      [string] $dateTimeFormat = 'o'
    }
      
    It 'GetDateTime parsable date' {
      [string] $currentDateTime = (Get-Date).ToUniversalTime().ToString($dateTimeFormat)

      [Nullable[DateTime]] $result = [Covert]::GetDateTime($currentDateTime)

      $result | 
        Should -Not -Be $null -Because "$currentDateTime is a parsable DateTime"
      $result.ToUniversalTime().ToString($dateTimeFormat) | 
        Should -Be $currentDateTime -Because "$currentDateTime is the same at value returned by GetDateTime"
    }

    It 'GetDateTime parsable DateTime' {
      [datetime[]] $originalValues = 
        [DateTime]::MinValue, [DateTime]::MaxValue,
        [DateTime]::new(1066, 10, 16), [DateTime]::new(1944, 6, 6, 10, 30, 30),
        [DateTime]::new(10, 10, 11, 9, 9, 9), [DateTime]::new(1944, 6, 6, 23, 59, 59), 
        [DateTime]::new(10, 10, 11, 0, 0, 0)

      foreach ($originalValue in $originalValues) {
        [Nullable[datetime]] $result = [Covert]::GetDateTime($originalvalue.ToString($dateTimeFormat))

        $result | 
          Should -Not -Be $null -Because "$originalvalue is a parsable DateTime"
        $result | 
          Should -Be $originalvalue -Because "$originalvalue is the same at value returned by GetDateTime"
      }
    }

    It 'GetDateTime unparsable date $null' {
      [Nullable[DateTime]] $result = [Covert]::GetDateTime($null)

      $result | 
        Should -Be $null -Because '$null is not a parsable DateTime'
    }

    It "GetDateTime unparsable date" {
      [string] $unparsableDate = 'abc'      
      [Nullable[DateTime]] $result = [Covert]::GetDateTime($unparsableDate)

      $result | 
        Should -Be $null -Because "$currentDateTime is not a parsable DateTime"
    }
  }

  Context 'method GetBool' {
    It 'GetBool parsable bool' {
      [string[]] $trueTextValues = 'True', 'TRUE', 'true', 'TrUe'

      foreach ($trueTextValue in $trueTextValues) {
        [Nullable[bool]] $result = [Covert]::GetBool($trueTextValue)

        $result | 
          Should -Not -Be $null -Because "$trueTextValue is a parsable bool"
        $result | 
          Should -BeTrue -Because "$trueTextValue is the same at value returned by GetBool"
      }

      [string[]] $falseTextValues = 'False', 'FALSE', 'false', 'FaLsE'

      foreach ($falseTextValue in $falseTextValues) {
        [Nullable[bool]] $result = [Covert]::GetBool($falseTextValue)

        $result | 
          Should -Not -Be $null -Because "$falseTextValue is a parsable bool"
        $result | 
          Should -BeFalse -Because "$falseTextValue is the same at value returned by GetBool"
      }
    }

    It 'GetBool unparsable bool $null' {
      [Nullable[bool]] $result = [Covert]::GetBool($null)

      $result | 
        Should -Be $null -Because '$null is not a parsable bool'
    }

    It "GetBool unparsable bool" {
      [string] $unparsableBool = 'abc'      
      [Nullable[bool]] $result = [Covert]::GetBool($unparsableDate)

      $result | 
        Should -Be $null -Because "$unparsableBool is not a parsable bool"
    }
  }  
  
  Context 'method GetInt' {
    It 'GetInt parsable int' {
      [int[]] $originalValues = [int]::MaxValue, [int]::MinValue, 0, 123, 456, 
      [short]::MinValue, [short]::MaxValue

      foreach ($originalValue in $originalValues) {
        [Nullable[int]] $result = [Covert]::GetInt($originalvalue.ToString())

        $result | 
          Should -Not -Be $null -Because "$originalvalue is a parsable int"
        $result | 
          Should -Be $originalvalue -Because "$originalvalue is the same at value returned by GetInt"
      }
    }

    It 'GetInt unparsable int $null' {
      [Nullable[int]] $result = [Covert]::GetInt($null)

      $result | 
        Should -Be $null -Because '$null is not a parsable int'
    }

    It "GetInt unparsable int" {
      [string] $unparsableInt = 'abc'      
      [Nullable[int]] $result = [Covert]::GetInt($unparsableDate)

      $result | 
        Should -Be $null -Because "$unparsableInt is not a parsable int"
    }
  }

  Context 'method GetDouble' {
    It 'GetDouble parsable double' {
      [double[]] $originalValues = 
            [double]::MaxValue, [double]::MinValue, 0, 123.33, 456.55, 
            [float]::MinValue, [float]::MaxValue, 
            [decimal]::MinValue, [decimal]::MaxValue

      foreach ($originalValue in $originalValues) {
        [Nullable[double]] $result = [Covert]::GetDouble($originalvalue.ToString())

        $result | 
          Should -Not -Be $null -Because "$originalvalue is a parsable double"
        $result | 
          Should -Be $originalvalue -Because "$originalvalue is the same at value returned by GetDouble"
      }
    }

    It 'GetDouble unparsable double $null' {
      [Nullable[double]] $result = [Covert]::GetDouble($null)

      $result | 
        Should -Be $null -Because '$null is not a parsable double'
    }

    It "GetDouble unparsable double" {
      [string] $unparsableDouble = 'abc'      
      [Nullable[double]] $result = [Covert]::GetDouble($unparsableDate)

      $result | 
        Should -Be $null -Because "$unparsableDouble is not a parsable double"
    }
  }    
}

The Covert.ps1 code was include in text form in the post, PowerShell: Safely Converting Strings to by Value Types, but is included follows:





Monday, July 5, 2021

PowerShell: Safely Converting Strings to by Value Types

Converting strings to value types like int, char, double, bool, DateTime, TimeSpan, and Guid without throwing an exception when invalid data is encountered requires some finesse.  One approach would be to return a $null of the conversion from string to value type fails. In a previous post, PowerShell: Nullable Types, nullable PowerShell types were introduced which facilitates returning a $null when a conversion from string to value type fails. Also in a previous post, the ref keyword was introduced (PowerShell: Passing Values by Reference (the ref Keyword)) which allowed PowerShell to take advantage of .NET's TryParse methods.

The PowerShell class below, Converts, demonstrates how to covert a string to type int, double, bool, and DateTime without generating an error/exception (using nullable types and the ref keyword):

class Convert {
  static [Nullable[DateTime]] GetDateTime([string] $candidate) {
    # Trying to pass in $result=$null causes TryParse to fail if 
    # $candidate is invalid
    Nullable[DateTime]] $result = Get-Date
    [bool] $valid = [DateTime]::TryParse( 
                            $candidate, 
                            [ref]$result)

    if (!($valid)) {
      $result = $null
    }

      return $result                
  }

  static [Nullable[int]] GetInt([string] $candidate) {
    [Nullable[int]] $result = $null
    [bool] $valid = [int]::TryParse( 
              $candidate, 
              [ref]$result)

    if (!($valid)) {
      $result = $null
    }

    return $result        
  }
  
  static [Nullable[bool]] GetBool([string] $candidate) {
    [Nullable[bool]] $result = $null
    [bool] $valid = [bool]::TryParse( 
              $candidate, 
              [ref]$result)

    if (!($valid)) {
      $result = $null
    }

    return $result        
  }
  
  static [Nullable[double]] GetDouble([string] $candidate) {
    [Nullable[double]] $result = $null
    [bool] $valid = [double]::TryParse( 
              $candidate, 
              [ref]$result)

    if (!($valid)) {
      $result = $null
    }

    return $result        
  }  
}

Code to test the above class is as follows:

[string] $badCandidate = 'abc'
[string] $goodCandidate = '12/25/2021 08:09:11'
[Nullable[DateTime]] $dateTimeResult = [Covert]::GetDateTime($badCandidate)

if ($null -ne $dateTimeResult) {
  throw "$badCandidate should result in an invalid DateTime"
}

$dateTimeResult = [Covert]::GetDateTime($null)

if ($null -ne $dateTimeResult) {
  throw "`$null should result in an invalid DateTime"
}

$dateTimeResult = [Covert]::GetDateTime($goodCandidate)
if ($null -eq $dateTimeResult) {
  throw "$goodCandidate should result in a valid DateTime"
}

$goodCandidate = '123'
[Nullable[int]] $intResult = [Covert]::GetInt($badCandidate)

if ($null -ne $intResult) {
  throw "$badCandidate should result in an invalid int"
}

$intResult = [Covert]::GetInt($null)

if ($null -ne $intResult) {
  throw "`$null should result in an invalid int"
}

$intResult = [Covert]::GetInt($goodCandidate)
if ($null -eq $intResult) {
  throw "$goodCandidate should result in a valid int"
}

$goodCandidate = 'False'
[Nullable[bool]] $boolResult = [Covert]::GetBool($badCandidate)

if ($null -ne $boolResult) {
  throw "$badCandidate should result in an invalid bool"
}

$boolResult = [Covert]::GetBool($null)

if ($null -ne $boolResult) {
  throw "`$null should result in an invalid bool"
}

$boolResult = [Covert]::GetBool($goodCandidate)
if ($null -eq $boolResult) {
  throw "$goodCandidate should result in a valid bool"
}

$goodCandidate = '123.123'
[Nullable[double]] $doubleResult = [Covert]::GetDouble($badCandidate)

if ($null -ne $doubleResult) {
  throw "$badCandidate should result in an invalid double"
}

$doubleResult = [Covert]::GetDouble($null)

if ($null -ne $doubleResult) {
  throw "`$null should result in an invalid double"
}

$doubleResult = [Covert]::GetDouble($goodCandidate)
if ($null -eq $doubleResult) {
  throw "$goodCandidate should result in a valid double"
}

Sunday, July 4, 2021

PowerShell: Passing Values by Reference (the ref Keyword)

In PowerShell parameters passed to functions and class method are by value meaning the value of the underlying reference cannot be change when the function or method is invoked. There are functions and methods that change the underlying reference using the C# ref or out keyword. .NET's TryParse methods, for example, use out parameters:

public static bool TryParse (string? s, out int result);
public static bool TryParse (string? s, out double result);
public static bool TryParse (string? s, out DateTime result);

PowerShell's ref keyword can be used pass a value to TryParse's result parameter which is decorated by out as is shown in boldface below:

[string] $candidate = 'abc'
[Nullable[int]] $result = $null
[bool] $isValid = [int]::TryParse($candidate, [ref] $result)

Write-Host "Value: $result, Is valid: $isValid, Is null: $($null -eq $result)"

$candidate = '123'
$isValid = [int]::TryParse($candidate, [ref] $result)
Write-Host "Value: $result, Is valid: $isValid, Is null: $($null -eq $result)"

PowerShell's ref keyword works for both the C# ref and out parameters.

The code sample above worked when TryParse was invoked for the int data type. Attempting the same code for the DateTime type is as follows and this case actually fails:

[string] $candidate = 'abc'
[Nullable[DateTime]] $result = $null
[bool] $isValid = [DateTime]::TryParse($candidate, [ref] $result)

Write-Host "Value: $result, Is valid: $isValid, Is null: $($null -eq $result)"

The output from invoking the code above is as follows:


As it turns out, in order for [DateTime]::TryParse to work with invalid parse strings, the value passed to the second parameter must be assigned to valid value of type DateTime as is shown below were $result is assigned the value of Get-Date as is shown in boldface:

[string] $candidate = 'abc'
[Nullable[DateTime]] $result = Get-Date
[bool] $isValid = [DateTime]::TryParse($candidate, [ref] $result)

if (!($isValid)) {
    $result = $null # $result was not set by TryParse
}

Write-Host "Value: $result, Is valid: $isValid, Is null: $($null -eq $result)"
$candidate = '12/25/2021 08:09:11'
$result = Get-Date
$isValid = [DateTime]::TryParse($candidate, [ref] $result)

if (!($isValid)) {
    $result = $null # $result was not set by TryParse
}

Write-Host "Value: $result, Is valid: $isValid, Is null: $($null -eq $result)"

The above code executes without an error.

Saturday, July 3, 2021

PowerShell: Nullable Types

When a variable or parameter is assigned a value of $null it is a convenient way to indicate that the value has not yet been assigned. For by value types such as int, float, char, Guid, DateTime, and TimeSpan the $null assignment has come caveats. This post will introduce PowerShell's Nullable types but first will show why Nullable types are needed.

PowerShell does not generate a warning or error when $null is assigned to an int, double or char variable. 

[int] $anyInt = $null
[double] $anyDouble = $null
[char] $anyChar = $null

Write-Host "AnyInt Value = $anyInt, Is `$null = $($anyInt -eq $null)" 
Write-Host "AnyDouble Value = $anyDouble, Is `$null = $($anyDouble -eq $null)" 
Write-Host "AnyChar Value = $anyChar, Is `$null = $($anyChar -eq $null), Integer Value = $([int]$anyChar)" 

The output of the above Write-Host cmdlets show that $null assignment results in a value of zero being assigned to each of the variables rather than a value of $null:


Assigning $null to variables of type Guid, DateTime, and TimeSpan is shown below:

[Guid] $anyGuid = $null
[DateTime] $anyDateTime = $null
[TimeSpan] $anyTimeSpan = $null

PowerShell displays a warning indicating a $null assignment is not valid for the above code:


Each of the by value data types (int, double, char, Guid, DateTime, and TimeSpan) can be assigned as nullable as shown below using the aptly named Nullable keyword:

[Nullable[int]] $anyInt = $null
[Nullable[double]] $anyDouble = $null
[Nullable[char]] $anyChar = $null
[Nullable[Guid]] $anyGuid = $null
[Nullable[DateTime]] $anyDateTime = $null
[Nullable[TimeSpan]] $anyTimeSpan = $null

Write-Host "AnyInt Value = $anyInt, Is `$null = $($anyInt -eq $null)" 
Write-Host "AnyDouble Value = $anyDouble, Is `$null = $($anyDouble -eq $null)" 
Write-Host "AnyChar Value = $anyChar, Is `$null = $($anyChar -eq $null)" 
Write-Host "AnyGuid Value = $anyGuid, Is `$null = $($anyGuid -eq $null)" 
Write-Host "AnyDateTime Value = $anyDatetime, Is `$null = $($anyDatetime -eq $null)" 
Write-Host "AnyTimeSpan Value = $anyTimeSpan, Is `$null = $($anyTimeSpan -eq $null)" 

When the above Write-Host cmdlets are invoked the value of $null is recognized as being assigned to the variables: