Search code examples
powershellhashtablepester

Show content of hashtable when Pester test case fails


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?


Solution

  • 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.