Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions docs/6.0.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,20 @@ $null | Should-Be -Expected $null
Should-HaveType -Actual ([int[]](1, 2)) -Expected ([int[]])
```

If you forget and pipe a collection into a value or type assertion, the unwrapping is never a silent
surprise: when it makes the assertion fail, the message adds a hint that explains what the pipeline
did and points you back to `-Actual`.

```powershell
[int[]](1, 2) | Should-HaveType ([int[]])
# Expected value to have type [int[]], but got [Object[]] @(1, 2).
#
# Hint: You piped a [int[]] into a type assertion, but the pipeline streams a multi-item
# collection and re-collects it as [Object[]], so the assertion saw [Object[]], not the
# [int[]] you piped. To assert the type of a collection, pass it as the -Actual argument
# instead of piping it, e.g. -Actual $value.
```

#### More examples

```powershell
Expand Down Expand Up @@ -377,6 +391,10 @@ targets **Windows PowerShell 5.1** and **PowerShell 7.2+**.
- **Array comparisons** in `Should -Be` now point at the first differing index and show the shape of
the input, instead of a flat "expected/but got".
- **String diffs** show an arrow marker at the first differing character.
- **Piping a collection into a value or type assertion** unwraps it (one item becomes a scalar,
several become `[object[]]`), so the assertion no longer sees your collection. When that makes an
assertion fail, the message adds a hint explaining what the pipeline did and pointing you to
`-Actual`.
- **Control characters and ANSI/VT sequences** in values are escaped (using Unicode Control
Pictures) so they can't corrupt the console or the NUnit/JUnit report.
- `Output.StackTraceVerbosity` (`None`, `FirstLine`, `Filtered`, `Full`), `Output.CIFormat`
Expand Down
2 changes: 2 additions & 0 deletions src/functions/assert/Boolean/Should-BeFalse.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@
$Actual = $collectedInput.Actual
if ($Actual -isnot [bool] -or $Actual) {
$Message = Get-AssertionMessage -Expected $false -Actual $Actual -Because $Because -DefaultMessage "Expected <expectedType> <expected>,<because> but got: <actualType> <actual>."
$hint = Get-AssertionGotcha -Cmdlet $PSCmdlet -Buffer $local:Input -CollectedActual $Actual -IsPipelineInput $collectedInput.IsPipelineInput -Expecting Scalar
if ($hint) { $Message = "$Message`n`nHint: $hint" }
Invoke-AssertionFailed -Message $Message -CallerCmdlet $PSCmdlet
}
Set-AssertionPassResult
Expand Down
2 changes: 2 additions & 0 deletions src/functions/assert/Boolean/Should-BeFalsy.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@
$Actual = $collectedInput.Actual
if ($Actual) {
$Message = Get-AssertionMessage -Expected $false -Actual $Actual -Because $Because -DefaultMessage 'Expected <expectedType> <expected> or a falsy value: 0, "", $null or @(),<because> but got: <actualType> <actual>.'
$hint = Get-AssertionGotcha -Cmdlet $PSCmdlet -Buffer $local:Input -CollectedActual $Actual -IsPipelineInput $collectedInput.IsPipelineInput -Expecting Scalar
if ($hint) { $Message = "$Message`n`nHint: $hint" }
Invoke-AssertionFailed -Message $Message -CallerCmdlet $PSCmdlet
}
Set-AssertionPassResult
Expand Down
2 changes: 2 additions & 0 deletions src/functions/assert/Boolean/Should-BeTrue.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@
$Actual = $collectedInput.Actual
if ($Actual -isnot [bool] -or -not $Actual) {
$Message = Get-AssertionMessage -Expected $true -Actual $Actual -Because $Because -DefaultMessage "Expected <expectedType> <expected>,<because> but got: <actualType> <actual>."
$hint = Get-AssertionGotcha -Cmdlet $PSCmdlet -Buffer $local:Input -CollectedActual $Actual -IsPipelineInput $collectedInput.IsPipelineInput -Expecting Scalar
if ($hint) { $Message = "$Message`n`nHint: $hint" }
Invoke-AssertionFailed -Message $Message -CallerCmdlet $PSCmdlet
}
Set-AssertionPassResult
Expand Down
2 changes: 2 additions & 0 deletions src/functions/assert/Boolean/Should-BeTruthy.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@
$Actual = $collectedInput.Actual
if (-not $Actual) {
$Message = Get-AssertionMessage -Expected $true -Actual $Actual -Because $Because -DefaultMessage "Expected <expectedType> <expected> or a truthy value,<because> but got: <actualType> <actual>."
$hint = Get-AssertionGotcha -Cmdlet $PSCmdlet -Buffer $local:Input -CollectedActual $Actual -IsPipelineInput $collectedInput.IsPipelineInput -Expecting Scalar
if ($hint) { $Message = "$Message`n`nHint: $hint" }
Invoke-AssertionFailed -Message $Message -CallerCmdlet $PSCmdlet
}
Set-AssertionPassResult
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ function Ensure-ExpectedIsNotCollection {

if (Is-Collection $InputObject)
{
throw [ArgumentException]'You provided a collection to the -Expected parameter. Using a collection on the -Expected side is not allowed by this assertion, because it leads to unexpected behavior. Please use Should-Any, Should-All or some other specialized collection assertion.'
throw [ArgumentException]'You provided a collection to the -Expected parameter. Using a collection on the -Expected side is not allowed by this assertion, because it leads to unexpected behavior. To compare collections use Should-BeCollection, or a more specialized collection assertion such as Should-Any or Should-All.'
}

$InputObject
Expand Down
78 changes: 77 additions & 1 deletion src/functions/assert/Common/Get-AssertionGotcha.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,87 @@ function Get-AssertionGotcha {
# lone scalar or $null is a perfectly valid one-item collection here, so
# only a dictionary -- which PowerShell silently passes through as a
# single, non-iterated object -- is a genuine gotcha.
[ValidateSet('Collection', 'CollectionItems')]
# ExactType - the input is checked as a single value against a type (e.g.
# Should-HaveType, Should-NotHaveType). Piping a collection unwraps it
# (one item becomes a scalar, several become [object[]]), so the original
# collection type is lost. Here a piped collection is the gotcha; a piped
# scalar is fine.
# Scalar - the input is inspected as a single value, but not for its type (e.g.
# Should-Be, the string/boolean/comparison/null/hashtable assertions).
# Piping a collection unwraps it the same way, so the assertion silently
# inspects the collapsed value instead of the collection. Unlike
# ExactType the wording is about the collection being flattened, not its
# type changing, so even an [object[]] that stays an [object[]] is worth
# pointing out.
[ValidateSet('Collection', 'CollectionItems', 'ExactType', 'Scalar')]
[string] $Expecting = 'Collection'
)

try {
if ($Expecting -eq 'ExactType') {
# Only piped input can be a gotcha here: a collection passed with -Actual keeps its real
# type and asserts correctly. The PipelineSource trick recovers the *original* left-hand
# side, so even though the assertion only ever sees the unwrapped remains we can tell:
# scalar - a genuine single value was piped; it keeps its type, so nothing to say.
# collection - a real collection was piped and the pipeline unwrapped it (the gotcha).
# range/etc. - nothing we can name with confidence, so stay quiet.
if (-not $IsPipelineInput) { return $null }
$info = [Pester.PipelineSource]::Resolve($Cmdlet, @($Buffer))
if ($info.Source -ne 'collection') { return $null }

# An empty collection is sent through the pipeline as no items at all, so nothing was
# unwrapped in the #2801 sense -- there is no surprising type change to explain.
if ($info.Count -eq 0) { return $null }

# The trick recovers the genuine piped type (e.g. [string[]]) and item count, neither of
# which the failure message can show because the pipeline already unwrapped the value.
# $CollectedActual is what the assertion actually compared, i.e. what the collection was
# unwrapped into. The recovered count tells us which unwrapping happened:
# one item -> the pipeline yields that single element, so a scalar reaches the assertion.
# many items -> the elements are streamed and re-collected into an [Object[]].
$pipedType = Get-ShortType2 -Value $info.Value
$seenType = Get-ShortType2 -Value $CollectedActual

# If the pipeline did not change the observable type (e.g. an [Object[]] is streamed and
# re-collected straight back into an [Object[]]), then the type was never lost and the
# failure is a genuine mismatch. Saying "saw [Object[]], not the [Object[]] you piped"
# would be nonsense, so there is nothing useful to hint.
if ($seenType -eq $pipedType) { return $null }

$advice = "To assert the type of a collection, pass it as the -Actual argument instead of piping it, e.g. -Actual `$value."

if ($info.Count -eq 1) {
return "You piped a $pipedType into a type assertion, but the pipeline unwraps a single-item collection to its one element, so the assertion saw a single $seenType, not the $pipedType you piped. $advice"
}

return "You piped a $pipedType into a type assertion, but the pipeline streams a multi-item collection and re-collects it as $seenType, so the assertion saw $seenType, not the $pipedType you piped. $advice"
}

if ($Expecting -eq 'Scalar') {
# Same gotcha as ExactType -- a piped collection is unwrapped before the assertion sees
# it -- but here the assertion does not care about the type, only the single value. So the
# story is "your collection was collapsed into one value and inspected as a whole", which
# is worth telling even when the collapsed value is still an [Object[]] (e.g. a piped
# [Object[]] re-collected as [Object[]]). That is why this branch has no "type did not
# change" guard, unlike ExactType.
if (-not $IsPipelineInput) { return $null }
$info = [Pester.PipelineSource]::Resolve($Cmdlet, @($Buffer))
if ($info.Source -ne 'collection') { return $null }

# An empty collection sends no items through the pipeline, so there is nothing that was
# collapsed and nothing surprising to explain.
if ($info.Count -eq 0) { return $null }

$pipedType = Get-ShortType2 -Value $info.Value
$seenType = Get-ShortType2 -Value $CollectedActual
$advice = "To assert on a collection use Should-BeCollection or Should-BeEquivalent; to assert on a single value pass it as the -Actual argument instead of piping it, e.g. -Actual `$value."

if ($info.Count -eq 1) {
return "You piped a $pipedType into a single-value assertion, but the pipeline unwraps a single-item collection to its one element, so the assertion inspected that single $seenType instead of the collection. $advice"
}

return "You piped a $pipedType into a single-value assertion, but the pipeline streams a multi-item collection and re-collects it into a single $seenType, so the whole collection was inspected as one value. $advice"
}
if ($IsPipelineInput) {
# Recover the original left-hand side, undoing the engine's single-item wrapping, so we
# can tell "a single hashtable was piped" (scalar) from "a real 1-item collection".
Expand Down
2 changes: 2 additions & 0 deletions src/functions/assert/General/Should-Be.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@

if ((Ensure-ExpectedIsNotCollection $Expected) -ne $Actual) {
$Message = Get-AssertionMessage -Expected $Expected -Actual $Actual -Because $Because -DefaultMessage "Expected <expectedType> <expected>,<because> but got <actualType> <actual>."
$hint = Get-AssertionGotcha -Cmdlet $PSCmdlet -Buffer $local:Input -CollectedActual $Actual -IsPipelineInput $collectedInput.IsPipelineInput -Expecting Scalar
if ($hint) { $Message = "$Message`n`nHint: $hint" }
Invoke-AssertionFailed -Message $Message -CallerCmdlet $PSCmdlet
}
Set-AssertionPassResult
Expand Down
18 changes: 17 additions & 1 deletion src/functions/assert/General/Should-BeGreaterThan.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,24 @@

$collectedInput = Collect-Input -ParameterInput $Actual -PipelineInput $local:Input -IsPipelineInput $MyInvocation.ExpectingInput -UnrollInput
$Actual = $collectedInput.Actual
if ((Ensure-ExpectedIsNotCollection $Expected) -ge $Actual) {
$expectedValue = Ensure-ExpectedIsNotCollection $Expected
# The comparison operators throw a native conversion error when $Actual is not a comparable
# scalar, which is exactly what happens when a collection is piped in and unwrapped to [object[]].
# Catch it so we can show the input hint instead of a cryptic "Could not compare" error. When it
# is not a piped-collection gotcha we have nothing to add, so the original error is rethrown.
$failed = $false
$comparisonError = $null
try {
$failed = $expectedValue -ge $Actual
}
catch {
$comparisonError = $_
}
if ($comparisonError -or $failed) {
$Message = Get-AssertionMessage -Expected $Expected -Actual $Actual -Because $Because -DefaultMessage "Expected the actual value to be greater than <expectedType> <expected>,<because> but it was not. Actual: <actualType> <actual>"
$hint = Get-AssertionGotcha -Cmdlet $PSCmdlet -Buffer $local:Input -CollectedActual $Actual -IsPipelineInput $collectedInput.IsPipelineInput -Expecting Scalar
if ($comparisonError -and -not $hint) { throw $comparisonError }
if ($hint) { $Message = "$Message`n`nHint: $hint" }
Invoke-AssertionFailed -Message $Message -CallerCmdlet $PSCmdlet
}
Set-AssertionPassResult
Expand Down
18 changes: 17 additions & 1 deletion src/functions/assert/General/Should-BeGreaterThanOrEqual.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,24 @@

$collectedInput = Collect-Input -ParameterInput $Actual -PipelineInput $local:Input -IsPipelineInput $MyInvocation.ExpectingInput -UnrollInput
$Actual = $collectedInput.Actual
if ((Ensure-ExpectedIsNotCollection $Expected) -gt $Actual) {
$expectedValue = Ensure-ExpectedIsNotCollection $Expected
# The comparison operators throw a native conversion error when $Actual is not a comparable
# scalar, which is exactly what happens when a collection is piped in and unwrapped to [object[]].
# Catch it so we can show the input hint instead of a cryptic "Could not compare" error. When it
# is not a piped-collection gotcha we have nothing to add, so the original error is rethrown.
$failed = $false
$comparisonError = $null
try {
$failed = $expectedValue -gt $Actual
}
catch {
$comparisonError = $_
}
if ($comparisonError -or $failed) {
$Message = Get-AssertionMessage -Expected $Expected -Actual $Actual -Because $Because -DefaultMessage "Expected the actual value to be greater than or equal to <expectedType> <expected>,<because> but it was not. Actual: <actualType> <actual>"
$hint = Get-AssertionGotcha -Cmdlet $PSCmdlet -Buffer $local:Input -CollectedActual $Actual -IsPipelineInput $collectedInput.IsPipelineInput -Expecting Scalar
if ($comparisonError -and -not $hint) { throw $comparisonError }
if ($hint) { $Message = "$Message`n`nHint: $hint" }
Invoke-AssertionFailed -Message $Message -CallerCmdlet $PSCmdlet
}
Set-AssertionPassResult
Expand Down
18 changes: 17 additions & 1 deletion src/functions/assert/General/Should-BeLessThan.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,24 @@

$collectedInput = Collect-Input -ParameterInput $Actual -PipelineInput $local:Input -IsPipelineInput $MyInvocation.ExpectingInput -UnrollInput
$Actual = $collectedInput.Actual
if ((Ensure-ExpectedIsNotCollection $Expected) -le $Actual) {
$expectedValue = Ensure-ExpectedIsNotCollection $Expected
# The comparison operators throw a native conversion error when $Actual is not a comparable
# scalar, which is exactly what happens when a collection is piped in and unwrapped to [object[]].
# Catch it so we can show the input hint instead of a cryptic "Could not compare" error. When it
# is not a piped-collection gotcha we have nothing to add, so the original error is rethrown.
$failed = $false
$comparisonError = $null
try {
$failed = $expectedValue -le $Actual
}
catch {
$comparisonError = $_
}
if ($comparisonError -or $failed) {
$Message = Get-AssertionMessage -Expected $Expected -Actual $Actual -Because $Because -DefaultMessage "Expected the actual value to be less than <expectedType> <expected>,<because> but it was not. Actual: <actualType> <actual>"
$hint = Get-AssertionGotcha -Cmdlet $PSCmdlet -Buffer $local:Input -CollectedActual $Actual -IsPipelineInput $collectedInput.IsPipelineInput -Expecting Scalar
if ($comparisonError -and -not $hint) { throw $comparisonError }
if ($hint) { $Message = "$Message`n`nHint: $hint" }
Invoke-AssertionFailed -Message $Message -CallerCmdlet $PSCmdlet
}
Set-AssertionPassResult
Expand Down
18 changes: 17 additions & 1 deletion src/functions/assert/General/Should-BeLessThanOrEqual.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,24 @@

$collectedInput = Collect-Input -ParameterInput $Actual -PipelineInput $local:Input -IsPipelineInput $MyInvocation.ExpectingInput -UnrollInput
$Actual = $collectedInput.Actual
if ((Ensure-ExpectedIsNotCollection $Expected) -lt $Actual) {
$expectedValue = Ensure-ExpectedIsNotCollection $Expected
# The comparison operators throw a native conversion error when $Actual is not a comparable
# scalar, which is exactly what happens when a collection is piped in and unwrapped to [object[]].
# Catch it so we can show the input hint instead of a cryptic "Could not compare" error. When it
# is not a piped-collection gotcha we have nothing to add, so the original error is rethrown.
$failed = $false
$comparisonError = $null
try {
$failed = $expectedValue -lt $Actual
}
catch {
$comparisonError = $_
}
if ($comparisonError -or $failed) {
$Message = Get-AssertionMessage -Expected $Expected -Actual $Actual -Because $Because -DefaultMessage "Expected the actual value to be less than or equal to <expectedType> <expected>,<because> but it was not. Actual: <actualType> <actual>"
$hint = Get-AssertionGotcha -Cmdlet $PSCmdlet -Buffer $local:Input -CollectedActual $Actual -IsPipelineInput $collectedInput.IsPipelineInput -Expecting Scalar
if ($comparisonError -and -not $hint) { throw $comparisonError }
if ($hint) { $Message = "$Message`n`nHint: $hint" }
Invoke-AssertionFailed -Message $Message -CallerCmdlet $PSCmdlet
}
Set-AssertionPassResult
Expand Down
2 changes: 2 additions & 0 deletions src/functions/assert/General/Should-BeNull.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@
$Actual = $collectedInput.Actual
if ($null -ne $Actual) {
$Message = Get-AssertionMessage -Expected $null -Actual $Actual -Because $Because -DefaultMessage "Expected `$null,<because> but got <actualType> '<actual>'."
$hint = Get-AssertionGotcha -Cmdlet $PSCmdlet -Buffer $local:Input -CollectedActual $Actual -IsPipelineInput $collectedInput.IsPipelineInput -Expecting Scalar
if ($hint) { $Message = "$Message`n`nHint: $hint" }
Invoke-AssertionFailed -Message $Message -CallerCmdlet $PSCmdlet
}
Set-AssertionPassResult
Expand Down
2 changes: 2 additions & 0 deletions src/functions/assert/General/Should-BeSame.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@
$Actual = $collectedInput.Actual
if (-not ([object]::ReferenceEquals($Expected, $Actual))) {
$Message = Get-AssertionMessage -Expected $Expected -Actual $Actual -Because $Because -DefaultMessage "Expected <expectedType> <expected>,<because> to be the same instance but it was not. Actual: <actualType> <actual>"
$hint = Get-AssertionGotcha -Cmdlet $PSCmdlet -Buffer $local:Input -CollectedActual $Actual -IsPipelineInput $collectedInput.IsPipelineInput -Expecting Scalar
if ($hint) { $Message = "$Message`n`nHint: $hint" }
Invoke-AssertionFailed -Message $Message -CallerCmdlet $PSCmdlet
}
Set-AssertionPassResult
Expand Down
Loading
Loading