Pester 测试用例失败时显示哈希表的内容

Show content of hashtable when Pester test case fails

问题

Hashtable 用作 Should 的输入时,Pester 仅输出类型名称而不是内容:

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

输出:

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

预期输出如:

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

原因

查看 Pester source,测试输入由私有函数 Format-Nicely 格式化,如果值类型为 Hashtable,它只是转换为 String。这归结为调用 Hashtable::ToString(),它只输出类型名。

解决方法

作为一种解决方法,我目前从 Hashtable 中派生了一个 class,它覆盖了 ToString 方法。在将输入传递给 Should 之前,我将其转换为这个自定义 class。这使得 Pester 在格式化测试结果时调用我重写的 ToString 方法。

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

现在 Pester 以 JSON 格式输出 Hashtable 内容,这对我来说已经足够了。

问题

有没有更优雅的方法来自定义 Hashtable 的 Pester 输出,不需要我更改每个测试用例的代码?

有点 hack,通过定义同名的全局别名来覆盖 Pester 的私有 Format-Nicely cmdlet。

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
}

这使我们能够像往常一样编写测试用例:

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

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

第一个测试用例的日志:

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

第二个测试用例的日志:

Expected $false, but got $true.

更简洁(尽管更冗长)的方法是为 Should.

编写包装函数

这样的包装器可以使用 System.Management.Automation.ProxyCommand 生成,但需要一些拼接才能以与 Shoulddynamicparam 块一起使用的方式生成它。有关详细信息,请参阅

wrappers process 块被修改为将当前管道对象转换为自定义 Hashtable 派生的 class,它覆盖 .ToString() 方法,然后再将其传递给原始 Should cmdlet 的 process 块。

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

要通过包装器覆盖 Pesters Should,请定义一个全局别名,如下所示:

Set-Alias Should MyShould -Force -Scope Global

并恢复原来的Should

Remove-Alias MyShould -Scope Global

备注:

  • 我还将 GetCommand() 的参数从 Should 更改为 Pester\Should 以避免由于别名导致的递归。不确定这是否真的有必要。
  • 需要最新版本的 Pester。使用 Pester 5.0.4 失败但使用 Pester 5.1.1 测试成功。