src/Equivalence/Assert-Equivalent.ps1
function Test-Same ($Expected, $Actual) { [object]::ReferenceEquals($Expected, $Actual) } function Is-CollectionSize ($Expected, $Actual) { if ($Expected.Length -is [Int] -and $Actual.Length -is [Int]) { return $Expected.Length -eq $Actual.Length } else { return $Expected.Count -eq $Actual.Count } } function Is-DataTableSize ($Expected, $Actual) { return $Expected.Rows.Count -eq $Actual.Rows.Count } function Get-ValueNotEquivalentMessage ($Expected, $Actual, $Property) { $Expected = Format-Nicely -Value $Expected $Actual = Format-Nicely -Value $Actual $propertyInfo = if ($Property) { " property $Property with value" } "Expected$propertyInfo '$Expected' to be equivalent to the actual value, but got '$Actual'." } function Get-CollectionSizeNotTheSameMessage ($Actual, $Expected, $Property) { $expectedLength = if ($Expected.Length -is [int]) {$Expected.Length} else {$Expected.Count} $actualLength = if ($Actual.Length -is [int]) {$Actual.Length} else {$Actual.Count} $Expected = Format-Collection -Value $Expected $Actual = Format-Collection -Value $Actual $propertyMessage = $null if ($property) { $propertyMessage = " in property $Property with values" } "Expected collection$propertyMessage '$Expected' with length '$expectedLength' to be the same size as the actual collection, but got '$Actual' with length '$actualLength'." } function Get-DataTableSizeNotTheSameMessage ($Actual, $Expected, $Property) { $expectedLength = $Expected.Rows.Count $actualLength = $Actual.Rows.Count $Expected = Format-Collection -Value $Expected $Actual = Format-Collection -Value $Actual $propertyMessage = $null if ($property) { $propertyMessage = " in property $Property with values" } "Expected DataTable$propertyMessage '$Expected' with length '$expectedLength' to be the same size as the actual DataTable, but got '$Actual' with length '$actualLength'." } function Compare-CollectionEquivalent ($Expected, $Actual, $Property) { if (-not (Is-Collection -Value $Expected)) { throw [ArgumentException]"Expected must be a collection." } if (-not (Is-Collection -Value $Actual)) { v -Difference "`$Actual is not a collection it is a $(Format-Nicely ($Actual.GetType())), so they are not equivalent." $expectedFormatted = Format-Collection -Value $Expected $expectedLength = $expected.Length $actualFormatted = Format-Nicely -Value $actual return "Expected collection '$expectedFormatted' with length '$expectedLength', but got '$actualFormatted'." } if (-not (Is-CollectionSize -Expected $Expected -Actual $Actual)) { v -Difference "`$Actual does not have the same size ($($Actual.Length)) as `$Expected ($($Expected.Length)) so they are not equivalent." return Get-CollectionSizeNotTheSameMessage -Expected $Expected -Actual $Actual -Property $Property } $eEnd = if ($Expected.Length -is [int]) {$Expected.Length} else {$Expected.Count} $aEnd = if ($Actual.Length -is [int]) {$Actual.Length} else {$Actual.Count} v "Comparing items in collection, `$Expected has lenght $eEnd, `$Actual has length $aEnd." $taken = @() $notFound = @() $anyDifferent = $false for ($e=0; $e -lt $eEnd; $e++) { # todo: retest strict order v "`nSearching for `$Expected[$e]:" $currentExpected = $Expected[$e] $found = $false if ($StrictOrder) { $currentActual = $Actual[$e] if ($taken -notcontains $e -and (-not (Compare-Equivalent -Expected $currentExpected -Actual $currentActual -Path $Property))) { $taken += $e $found = $true v -Equivalence "`Found `$Expected[$e]." } } else { for ($a=0; $a -lt $aEnd; $a++) { # we already took this item as equivalent to an item # in the expected collection, skip it if ($taken -contains $a) { v "Skipping `$Actual[$a] because it is already taken." continue } $currentActual = $Actual[$a] # -not, because $null means no differences, and some strings means there are differences v "Comparing `$Actual[$a] to `$Expected[$e] to see if they are equivalent." if (-not (Compare-Equivalent -Expected $currentExpected -Actual $currentActual -Path $Property)) { # add the index to the list of taken items so we can skip it # in the search, this way we can compare collections with # arrays multiple same items $taken += $a $found = $true v -Equivalence "`Found equivalent item for `$Expected[$e] at `$Actual[$a]." # we already found the item we # can move on to the next item in Exected array break } } } if (-not $found) { v -Difference "`$Actual does not contain `$Expected[$e]." $anyDifferent = $true $notFound += $currentExpected } } # do not depend on $notFound collection here # failing to find a single $null, will return # @($null) which evaluates to false, even though # there was a single item that we did not find if ($anyDifferent) { v -Difference "`$Actual and `$Expected arrays are not equivalent." $Expected = Format-Nicely -Value $Expected $Actual = Format-Nicely -Value $Actual $notFoundFormatted = Format-Nicely -Value ( $notFound | % { Format-Nicely -Value $_ } ) $propertyMessage = if ($Property) {" in property $Property which is"} return "Expected collection$propertyMessage '$Expected' to be equivalent to '$Actual' but some values were missing: '$notFoundFormatted'." } v -Equivalence "`$Actual and `$Expected arrays are equivalent." } function Compare-DataTableEquivalent ($Expected, $Actual, $Property) { if (-not (Is-DataTable -Value $Expected)) { throw [ArgumentException]"Expected must be a DataTable." } if (-not (Is-DataTable -Value $Actual)) { $expectedFormatted = Format-Collection -Value $Expected $expectedLength = $expected.Rows.Count $actualFormatted = Format-Nicely -Value $actual return "Expected DataTable '$expectedFormatted' with length '$expectedLength', but got '$actualFormatted'." } if (-not (Is-DataTableSize -Expected $Expected -Actual $Actual)) { return Get-DataTableSizeNotTheSameMessage -Expected $Expected -Actual $Actual -Property $Property } $eEnd = $Expected.Rows.Count $aEnd = $Actual.Rows.Count $taken = @() $notFound = @() for ($e = 0; $e -lt $eEnd; $e++) { $currentExpected = $Expected.Rows[$e] $found = $false if ($StrictOrder) { $currentActual = $Actual.Rows[$e] if ((-not (Compare-Equivalent -Expected $currentExpected -Actual $currentActual -Path $Property)) -and $taken -notcontains $e) { $taken += $e $found = $true } } else { for ($a = 0; $a -lt $aEnd; $a++) { $currentActual = $Actual.Rows[$a] if ((-not (Compare-Equivalent -Expected $currentExpected -Actual $currentActual -Path $Property)) -and $taken -notcontains $a) { $taken += $a $found = $true } } } if (-not $found) { $notFound += $currentExpected } } $Expected = Format-Nicely -Value $Expected $Actual = Format-Nicely -Value $Actual $notFoundFormatted = Format-Nicely -Value ( $notFound | % { Format-Nicely -Value $_ } ) if ($notFound) { $propertyMessage = if ($Property) {" in property $Property which is"} return "Expected DataTable$propertyMessage '$Expected' to be equivalent to '$Actual' but some values were missing: '$notFoundFormatted'." } } function Compare-ValueEquivalent ($Actual, $Expected, $Property) { $Expected = $($Expected) if (-not (Is-Value -Value $Expected)) { throw [ArgumentException]"Expected must be a Value." } # fix that string 'false' becomes $true boolean if ($Actual -is [Bool] -and $Expected -is [string] -and "$Expected" -eq 'False') { v "`$Actual is a boolean, and `$Expected is a 'False' string, which we consider equivalent to boolean `$false. Setting `$Expected to `$false." $Expected = $false if ($Expected -ne $Actual) { v -Difference "`$Actual is not equivalent to $(Format-Nicely $Expected) because it is $(Format-Nicely $Actual)." return Get-ValueNotEquivalentMessage -Expected $Expected -Actual $Actual -Property $Property } v -Equivalence "`$Actual is equivalent to $(Format-Nicely $Expected) because it is $(Format-Nicely $Actual)." return } if ($Expected -is [Bool] -and $Actual -is [string] -and "$Actual" -eq 'False') { v "`$Actual is a 'False' string, which we consider equivalent to boolean `$false. `$Expected is a boolean. Setting `$Actual to `$false." $Actual = $false if ($Expected -ne $Actual) { v -Difference "`$Actual is not equivalent to $(Format-Nicely $Expected) because it is $(Format-Nicely $Actual)." return Get-ValueNotEquivalentMessage -Expected $Expected -Actual $Actual -Property $Property } v -Equivalence "`$Actual is equivalent to $(Format-Nicely $Expected) because it is $(Format-Nicely $Actual)." return } #fix that scriptblocks are compared by reference if (Is-ScriptBlock -Value $Expected) { # todo: compare by equivalency like strings? v "`$Expected is a ScriptBlock, scriptblocks are considered equivalent when their content is equal. Converting `$Expected to string." #forcing scriptblock to serialize to string and then comparing that if ("$Expected" -ne $Actual) { # todo: difference on index? v -Difference "`$Actual is not equivalent to `$Expected because their contents differ." return Get-ValueNotEquivalentMessage -Expected $Expected -Actual $Actual -Property $Path } v -Equivalence "`$Actual is equivalent to `$Expected because their contents are equal." return } v "Comparing values as $(Format-Nicely ($Expected.GetType())) because `$Expected has that type." # todo: shorter messages when both sides have the same type (do not compare by using -is, instead query the type and compare it) because -is is true even for parent types $type = $Expected.GetType() $coalescedActual = $Actual -as $type if ($Expected -ne $Actual) { v -Difference "`$Actual is not equivalent to $(Format-Nicely $Expected) because it is $(Format-Nicely $Actual), and $(Format-Nicely $Actual) coalesced to $(Format-Nicely $type) is $(Format-Nicely $coalescedActual)." return Get-ValueNotEquivalentMessage -Expected $Expected -Actual $Actual -Property $Property } v -Equivalence "`$Actual is equivalent to $(Format-Nicely $Expected) because it is $(Format-Nicely $Actual), and $(Format-Nicely $Actual) coalesced to $(Format-Nicely $type) is $(Format-Nicely $coalescedActual)." } function Compare-HashtableEquivalent ($Actual, $Expected, $Property) { if (-not (Is-Hashtable -Value $Expected)) { throw [ArgumentException]"Expected must be a hashtable." } if (-not (Is-Hashtable -Value $Actual)) { v -Difference "`$Actual is not a hashtable it is a $(Format-Nicely ($Actual.GetType())), so they are not equivalent." $expectedFormatted = Format-Nicely -Value $Expected $actualFormatted = Format-Nicely -Value $Actual return "Expected hashtable '$expectedFormatted', but got '$actualFormatted'." } # todo: if either side or both sides are empty hashtable make the verbose output shorter and nicer $actualKeys = $Actual.Keys $expectedKeys = $Expected.Keys v "`Comparing all ($($expectedKeys.Count)) keys from `$Expected to keys in `$Actual." $result = @() foreach ($k in $expectedKeys) { $actualHasKey = $actualKeys -contains $k if (-not $actualHasKey) { v -Difference "`$Actual is missing key '$k'." $result += "Expected has key '$k' that the other object does not have." continue } $expectedValue = $Expected[$k] $actualValue = $Actual[$k] v "Both `$Actual and `$Expected have key '$k', comparing thier contents." $result += Compare-Equivalent -Expected $expectedValue -Actual $actualValue -Path "$Property.$k" } $keysNotInExpected = $actualKeys | where {$expectedKeys -notcontains $_ } if ($keysNotInExpected) { v -Difference "`$Actual has $($keysNotInExpected.Count) keys that were not found on `$Expected: $(Format-Nicely @($keysNotInExpected))." } else { v "`$Actual has no keys that we did not find on `$Expected." } foreach ($k in $keysNotInExpected) { $result += "Expected is missing key '$k' that the other object has." } if ($result) { v -Difference "Hastables `$Actual and `$Expected are not equivalent." $expectedFormatted = Format-Nicely -Value $Expected $actualFormatted = Format-Nicely -Value $Actual return "Expected hashtable '$expectedFormatted', but got '$actualFormatted'.`n$($result -join "`n")" } v -Equivalence "Hastables `$Actual and `$Expected are equivalent." } function Compare-DictionaryEquivalent ($Actual, $Expected, $Property) { if (-not (Is-Dictionary -Value $Expected)) { throw [ArgumentException]"Expected must be a dictionary." } if (-not (Is-Dictionary -Value $Actual)) { v -Difference "`$Actual is not a dictionary it is a $(Format-Nicely ($Actual.GetType())), so they are not equivalent." $expectedFormatted = Format-Nicely -Value $Expected $actualFormatted = Format-Nicely -Value $Actual return "Expected dictionary '$expectedFormatted', but got '$actualFormatted'." } # todo: if either side or both sides are empty dictionary make the verbose output shorter and nicer $actualKeys = $Actual.Keys $expectedKeys = $Expected.Keys v "`Comparing all ($($expectedKeys.Count)) keys from `$Expected to keys in `$Actual." $result = @() foreach ($k in $expectedKeys) { $actualHasKey = $actualKeys -contains $k if (-not $actualHasKey) { v -Difference "`$Actual is missing key '$k'." $result += "Expected has key '$k' that the other object does not have." continue } $expectedValue = $Expected[$k] $actualValue = $Actual[$k] v "Both `$Actual and `$Expected have key '$k', comparing thier contents." $result += Compare-Equivalent -Expected $expectedValue -Actual $actualValue -Path "$Property.$k" } $keysNotInExpected = $actualKeys | where {$expectedKeys -notcontains $_ } if ($keysNotInExpected) { v -Difference "`$Actual has $($keysNotInExpected.Count) keys that were not found on `$Expected: $(Format-Nicely @($keysNotInExpected))." } else { v "`$Actual has no keys that we did not find on `$Expected." } foreach ($k in $keysNotInExpected) { $result += "Expected is missing key '$k' that the other object has." } if ($result) { v -Difference "Hastables `$Actual and `$Expected are not equivalent." $expectedFormatted = Format-Nicely -Value $Expected $actualFormatted = Format-Nicely -Value $Actual return "Expected dictionary '$expectedFormatted', but got '$actualFormatted'.`n$($result -join "`n")" } v -Equivalence "Hastables `$Actual and `$Expected are equivalent." } function Compare-ObjectEquivalent ($Actual, $Expected, $Property) { if (-not (Is-Object -Value $Expected)) { throw [ArgumentException]"Expected must be an object." } if (-not (Is-Object -Value $Actual)) { v -Difference "`$Actual is not an object it is a $(Format-Nicely ($Actual.GetType())), so they are not equivalent." $expectedFormatted = Format-Nicely -Value $Expected $actualFormatted = Format-Nicely -Value $Actual return "Expected object '$expectedFormatted', but got '$actualFormatted'." } $actualProperties = $Actual.PsObject.Properties $expectedProperties = $Expected.PsObject.Properties v "Comparing all ($(@($expectedProperties).Count)) properties of `$Expected to `$Actual." foreach ($p in $expectedProperties) { $propertyName = $p.Name $actualProperty = $actualProperties | Where { $_.Name -eq $propertyName} if (-not $actualProperty) { v -Difference "Property '$propertyName' was not found on `$Actual." "Expected has property '$PropertyName' that the other object does not have." continue } v "Property '$propertyName` was found on `$Actual, comparing them for equivalence." $differences = Compare-Equivalent -Expected $p.Value -Actual $actualProperty.Value -Path "$Property.$propertyName" if (-not $differences) { v -Equivalence "Property '$propertyName` is equivalent." } else { v -Difference "Property '$propertyName` is not equivalent." } $differences } #check if there are any extra actual object props $expectedPropertyNames = $expectedProperties | select -ExpandProperty Name $propertiesNotInExpected = $actualProperties | where {$expectedPropertyNames -notcontains $_.name } if ($propertiesNotInExpected) { v -Difference "`$Actual has ($(@($propertiesNotInExpected).Count)) properties that `$Expected does not have: $(Format-Nicely @($propertiesNotInExpected))." } else { v -Equivalence "`$Actual has no extra properties that `$Expected does not have." } foreach ($p in $propertiesNotInExpected) { "Expected is missing property '$($p.Name)' that the other object has." } } function Compare-DataRowEquivalent ($Actual, $Expected, $Property) { if (-not (Is-DataRow -Value $Expected)) { throw [ArgumentException]"Expected must be a DataRow." } if (-not (Is-DataRow -Value $Actual)) { $expectedFormatted = Format-Nicely -Value $Expected $actualFormatted = Format-Nicely -Value $Actual return "Expected DataRow '$expectedFormatted', but got '$actualFormatted'." } $actualProperties = $Actual.PsObject.Properties | Where-Object Name -NotIn 'RowError','RowState','Table','ItemArray','HasErrors' $expectedProperties = $Expected.PsObject.Properties | Where-Object Name -NotIn 'RowError','RowState','Table','ItemArray','HasErrors' foreach ($p in $expectedProperties) { $propertyName = $p.Name $actualProperty = $actualProperties | Where { $_.Name -eq $propertyName} if (-not $actualProperty) { "Expected has property '$PropertyName' that the other object does not have." continue } Compare-Equivalent -Expected $p.Value -Actual $actualProperty.Value -Path "$Property.$propertyName" } #check if there are any extra actual object props $expectedPropertyNames = $expectedProperties | select -ExpandProperty Name $propertiesNotInExpected = $actualProperties | where {$expectedPropertyNames -notcontains $_.name } foreach ($p in $propertiesNotInExpected) { "Expected is missing property '$($p.Name)' that the other object has." } } function v { [CmdletBinding()] param( [String] $String, [Switch] $Difference, [Switch] $Equivalence ) # we are using implict variable $Path # from the parent scope, this is ugly # and bad practice, but saves us ton of # coding and boilerplate code $p = "" $p += if ($null -ne $Path) { "($Path)" } $p += if ($Difference) { " DIFFERENCE" } $p += if ($Equivalence) { " EQUIVALENCE" } $p += if (""-ne $p) { " - " } Write-Verbose ("$p$String".Trim() + " ") } # compares two objects for equivalency and returns $null when they are equivalent # or a string message when they are not function Compare-Equivalent { [CmdletBinding()] param($Actual, $Expected, $Path) #start by null checks to avoid implementing null handling #logic in the functions that follow if ($null -eq $Expected) { v "`$Expected is `$null, so we are expecting `$null." if ($Expected -ne $Actual) { v -Difference "`$Actual is not equivalent to $(Format-Nicely $Expected), because it has a value of type $(Format-Nicely $Actual.GetType())." return Get-ValueNotEquivalentMessage -Expected $Expected -Actual $Actual -Property $Path } # we terminate here, either we passed the test and return nothing, or we did not # and the previous statement returned message v -Equivalence "`$Actual is equivalent to `$null, because it is `$null." return } if ($null -eq $Actual) { v -Difference "`$Actual is $(Format-Nicely), but `$Expected has value of type $(Format-Nicely $Expected.GetType()), so they are not equivalent." return Get-ValueNotEquivalentMessage -Expected $Expected -Actual $Actual -Property $Path } v "`$Expected has type $($Expected.GetType()), `$Actual has type $($Actual.GetType()), they are both non-null." #test value types, strings, and single item arrays with values in them as values #expand the single item array to get to the value in it if (Is-Value -Value $Expected) { v "`$Expected is a value (value type, string, single value array, or a scriptblock), we will be comparing `$Actual to value types." Compare-ValueEquivalent -Actual $Actual -Expected $Expected -Property $Path return } #are the same instance if (Test-Same -Expected $Expected -Actual $Actual) { v -Equivalence "`$Expected and `$Actual are equivalent because they are the same object (by reference)." return } if (Is-Hashtable -Value $Expected) { v "`$Expected is a hashtable, we will be comparing `$Actual to hashtables." Compare-HashtableEquivalent -Expected $Expected -Actual $Actual -Property $Path return } # dictionaries? (they are IEnumerable so they must go before collections) if (Is-Dictionary -Value $Expected) { v "`$Expected is a dictionary, we will be comparing `$Actual to dictionaries." Compare-DictionaryEquivalent -Expected $Expected -Actual $Actual -Property $Path return } #compare DataTable if (Is-DataTable -Value $Expected) { # todo add verbose output to data table v "`$Expected is a datatable, we will be comparing `$Actual to datatables." Compare-DataTableEquivalent -Expected $Expected -Actual $Actual -Property $Path return } #compare collection if (Is-Collection -Value $Expected) { v "`$Expected is a collection, we will be comparing `$Actual to collections." Compare-CollectionEquivalent -Expected $Expected -Actual $Actual -Property $Path return } #compare DataRow if (Is-DataRow -Value $Expected) { # todo add verbose output to data row v "`$Expected is a datarow, we will be comparing `$Actual to datarows." Compare-DataRowEquivalent -Expected $Expected -Actual $Actual -Property $Path return } v "`$Expected is an object of type $($Expected.GetType()), we will be comparing `$Actual to objects." Compare-ObjectEquivalent -Expected $Expected -Actual $Actual -Property $Path } function Assert-Equivalent { [CmdletBinding()] param( $Actual, $Expected, [Switch]$StrictOrder ) $Option = $null $areDifferent = Compare-Equivalent -Actual $Actual -Expected $Expected | Out-String if ($areDifferent) { $message = Get-AssertionMessage -Actual $actual -Expected $Expected -Option $Option -Pretty -CustomMessage "Expected and actual are not equivalent!`nExpected:`n<expected>`n`nActual:`n<actual>`n`nSummary:`n$areDifferent`n<options>" throw [Assertions.AssertionException]$message } v -Equivalence "`$Actual and `$Expected are equivalent." } |