Show content of hashtable when Pester test case fails
Asked Answered
T

2

3

Problem

When a Hashtable is used as input for Should, Pester outputs only the typename instead of the content:

Describe 'test' {
    It 'test case' {
        $ht = @{ foo = 21; bar = 42 }
        $ht | Should -BeNullOrEmpty
    }
}

Output:

Expected $null or empty, but got @(System.Collections.Hashtable).

Expected output like:

Expected $null or empty, but got @{ foo = 21; bar = 42 }.

Cause

Looking at Pester source, the test input is formatted by private function Format-Nicely, which just casts to String if the value type is Hashtable. This boils down to calling Hashtable::ToString(), which just outputs the typename.

Workaround

As a workaround I'm currently deriving a class from Hashtable that overrides the ToString method. Before passing the input to Should, I cast it to this custom class. This makes Pester call my overridden ToString method when formatting the test result.

BeforeAll {
    class MyHashTable : Hashtable {
        MyHashTable( $obj ) : base( $obj ) {}
        [string] ToString() { return $this | ConvertTo-Json }
    }
}

Describe 'test' {
    It 'test case' {
        $ht = @{ foo = 21; bar = 42 }
        [MyHashTable] $ht | Should -BeNullOrEmpty
    }
}

Now Pester outputs the Hashtable content in JSON format, which is good enough for me.

Question

Is there a more elegant way to customize Pester output of Hashtable, which doesn't require me to change the code of each test case?

Teenateenage answered 6/1, 2021 at 19:38 Comment(0)
T
0

A cleaner (albeit more lengthy) way than my previous answer is to write a wrapper function for Should.

Such a wrapper can be generated using System.Management.Automation.ProxyCommand, but it requires a little bit of stitchwork to generate it in a way that it works with the dynamicparam block of Should. For details see this answer.

The wrappers process block is modified to cast the current pipeline object to a custom Hashtable-derived class that overrides the .ToString() method, before passing it to the process block of the original Should cmdlet.

class MyJsonHashTable : Hashtable {
    MyJsonHashTable ( $obj ) : base( $obj ) {}
    [string] ToString() { return $this | ConvertTo-Json }
}

Function MyShould {
    [CmdletBinding()]
    param(
        [Parameter(Position=0, ValueFromPipeline=$true, ValueFromRemainingArguments=$true)]
        [System.Object]
        ${ActualValue}
    )
    dynamicparam {
        try {
            $targetCmd = $ExecutionContext.InvokeCommand.GetCommand('Pester\Should', [System.Management.Automation.CommandTypes]::Function, $PSBoundParameters)
            $dynamicParams = @($targetCmd.Parameters.GetEnumerator() | Microsoft.PowerShell.Core\Where-Object { $_.Value.IsDynamic })
            if ($dynamicParams.Length -gt 0)
            {
                $paramDictionary = [Management.Automation.RuntimeDefinedParameterDictionary]::new()
                foreach ($param in $dynamicParams)
                {
                    $param = $param.Value
    
                    if(-not $MyInvocation.MyCommand.Parameters.ContainsKey($param.Name))
                    {
                        $dynParam = [Management.Automation.RuntimeDefinedParameter]::new($param.Name, $param.ParameterType, $param.Attributes)
                        $paramDictionary.Add($param.Name, $dynParam)
                    }
                }
    
                return $paramDictionary
            }
        } catch {
            throw
        }        
    }
    begin {
        try {
            $outBuffer = $null
            if ($PSBoundParameters.TryGetValue('OutBuffer', [ref]$outBuffer))
            {
                $PSBoundParameters['OutBuffer'] = 1
            }
    
            $wrappedCmd = $ExecutionContext.InvokeCommand.GetCommand('Pester\Should', [System.Management.Automation.CommandTypes]::Function)
            $scriptCmd = {& $wrappedCmd @PSBoundParameters }
    
            $steppablePipeline = $scriptCmd.GetSteppablePipeline()
            $steppablePipeline.Begin($PSCmdlet)
        } catch {
            throw
        }
    
    }
    process {
        try {
            # In case input object is a Hashtable, cast it to our derived class to customize Pester output.
            $item = switch( $_ ) {
                { $_ -is [Hashtable] } { [MyJsonHashTable] $_ }
                default                { $_ }
            }
            $steppablePipeline.Process( $item )
        } catch {
            throw
        }        
    }
    end {        
        try {
            $steppablePipeline.End()
        } catch {
            throw
        }        
    }
}

To override Pesters Should by the wrapper, define a global alias like this:

Set-Alias Should MyShould -Force -Scope Global

And to restore the original Should:

Remove-Alias MyShould -Scope Global

Notes:

  • I have also changed the argument of GetCommand() from Should to Pester\Should to avoid recursion due to the alias. Not sure if this is actually necessary though.
  • A recent version of Pester is required. Failed with Pester 5.0.4 but tested successfully with Pester 5.1.1.
Teenateenage answered 8/1, 2021 at 14:15 Comment(0)
T
2

Somewhat of a hack, override Pester's private Format-Nicely cmdlet by defining a global alias of the same name.

BeforeAll {
    InModuleScope Pester {
        # HACK: make private Pester cmdlet available for our custom override
        Export-ModuleMember Format-Nicely
    }

    function global:Format-NicelyCustom( $Value, [switch]$Pretty ) {
        if( $Value -is [Hashtable] ) {
            return $Value | ConvertTo-Json
        }
        # Call original cmdlet of Pester
        Pester\Format-Nicely $Value -Pretty:$Pretty
    }

    # Overrides Pesters Format-Nicely as global aliases have precedence over functions
    New-Alias -Name 'Format-Nicely' -Value 'Format-NicelyCustom' -Scope Global
}

This enables us to write test cases as usual:

Describe 'test' {
    It 'logs hashtable content' {
        $ht = @{ foo = 21; bar = 42 }
        $ht | Should -BeNullOrEmpty
    }   

    It 'logs other types regularly' {
        $true | Should -Be $false 
    }
}

Log of 1st test case:

Expected $null or empty, but got @({
 "foo": 21,
 "bar": 42
}).

Log of 2nd test case:

Expected $false, but got $true.
Teenateenage answered 6/1, 2021 at 21:39 Comment(4)
Nicely done; I was going to suggest Update-TypeData to create a type-level .ToString() override, but it turns out that only works with explicit .ToString() calls, and not with casts and string interpolation, which smells like a bug (as of v7.1) - see GitHub issue #14561Bordeaux
@Bordeaux Another, less hackish way I thought of was to override Should, which could cast the input value to a custom Hashtable with overridden .ToString(), before forwarding it to Pester\Should. This seems to be as easy as defining a global Should by the test code, but I couldn't get parameter forwarding to work. Possibly worth another question.Teenateenage
Re parameter forwarding: just in case you're not aware of proxy (wrapper) functions: https://mcmap.net/q/16021/-is-there-a-way-to-create-an-alias-to-a-cmdlet-in-a-way-that-it-only-runs-if-arguments-are-passed-to-the-alias. Given that the standard stringification of hashtables is virtually useless, the Update-TypeData solution is most appealing to me personally, but that obviously requires a bug fix; if you agree that it should be fixed, I encourage you to give the linked GitHub issue a thumbs-up (they do matter).Bordeaux
@Bordeaux I have upvoted on GitHub. I had tried the proxy wrapper, but running it produced error "Should: Parameter set cannot be resolved using the specified named parameters. One or more parameters issued cannot be used together or an insufficient number of parameters were provided." -- It looks like the wrapper is missing the dynamicparam declaration of Should.Teenateenage
T
0

A cleaner (albeit more lengthy) way than my previous answer is to write a wrapper function for Should.

Such a wrapper can be generated using System.Management.Automation.ProxyCommand, but it requires a little bit of stitchwork to generate it in a way that it works with the dynamicparam block of Should. For details see this answer.

The wrappers process block is modified to cast the current pipeline object to a custom Hashtable-derived class that overrides the .ToString() method, before passing it to the process block of the original Should cmdlet.

class MyJsonHashTable : Hashtable {
    MyJsonHashTable ( $obj ) : base( $obj ) {}
    [string] ToString() { return $this | ConvertTo-Json }
}

Function MyShould {
    [CmdletBinding()]
    param(
        [Parameter(Position=0, ValueFromPipeline=$true, ValueFromRemainingArguments=$true)]
        [System.Object]
        ${ActualValue}
    )
    dynamicparam {
        try {
            $targetCmd = $ExecutionContext.InvokeCommand.GetCommand('Pester\Should', [System.Management.Automation.CommandTypes]::Function, $PSBoundParameters)
            $dynamicParams = @($targetCmd.Parameters.GetEnumerator() | Microsoft.PowerShell.Core\Where-Object { $_.Value.IsDynamic })
            if ($dynamicParams.Length -gt 0)
            {
                $paramDictionary = [Management.Automation.RuntimeDefinedParameterDictionary]::new()
                foreach ($param in $dynamicParams)
                {
                    $param = $param.Value
    
                    if(-not $MyInvocation.MyCommand.Parameters.ContainsKey($param.Name))
                    {
                        $dynParam = [Management.Automation.RuntimeDefinedParameter]::new($param.Name, $param.ParameterType, $param.Attributes)
                        $paramDictionary.Add($param.Name, $dynParam)
                    }
                }
    
                return $paramDictionary
            }
        } catch {
            throw
        }        
    }
    begin {
        try {
            $outBuffer = $null
            if ($PSBoundParameters.TryGetValue('OutBuffer', [ref]$outBuffer))
            {
                $PSBoundParameters['OutBuffer'] = 1
            }
    
            $wrappedCmd = $ExecutionContext.InvokeCommand.GetCommand('Pester\Should', [System.Management.Automation.CommandTypes]::Function)
            $scriptCmd = {& $wrappedCmd @PSBoundParameters }
    
            $steppablePipeline = $scriptCmd.GetSteppablePipeline()
            $steppablePipeline.Begin($PSCmdlet)
        } catch {
            throw
        }
    
    }
    process {
        try {
            # In case input object is a Hashtable, cast it to our derived class to customize Pester output.
            $item = switch( $_ ) {
                { $_ -is [Hashtable] } { [MyJsonHashTable] $_ }
                default                { $_ }
            }
            $steppablePipeline.Process( $item )
        } catch {
            throw
        }        
    }
    end {        
        try {
            $steppablePipeline.End()
        } catch {
            throw
        }        
    }
}

To override Pesters Should by the wrapper, define a global alias like this:

Set-Alias Should MyShould -Force -Scope Global

And to restore the original Should:

Remove-Alias MyShould -Scope Global

Notes:

  • I have also changed the argument of GetCommand() from Should to Pester\Should to avoid recursion due to the alias. Not sure if this is actually necessary though.
  • A recent version of Pester is required. Failed with Pester 5.0.4 but tested successfully with Pester 5.1.1.
Teenateenage answered 8/1, 2021 at 14:15 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.