Skip to content

Add assertSameDictionaryKeysValues to compare keys and values without regard to key order #6282

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
Open
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
16 changes: 16 additions & 0 deletions src/Framework/Assert.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
use PHPUnit\Framework\Constraint\Callback;
use PHPUnit\Framework\Constraint\Constraint;
use PHPUnit\Framework\Constraint\Count;
use PHPUnit\Framework\Constraint\Dictionary\IsIdenticalKeysValues;
use PHPUnit\Framework\Constraint\DirectoryExists;
use PHPUnit\Framework\Constraint\FileExists;
use PHPUnit\Framework\Constraint\GreaterThan;
Expand Down Expand Up @@ -1763,6 +1764,21 @@ final public static function assertNotSame(mixed $expected, mixed $actual, strin
);
}

/**
* Assert that two arrays have the same keys and values for those keys.
* The order of the keys is ignored.
*
* @throws ExpectationFailedException
*/
final public static function assertSameDictionaryKeysValues(mixed $expected, mixed $actual, string $message = ''): void
{
self::assertThat(
$actual,
new IsIdenticalKeysValues($expected),
$message,
);
}

/**
* Asserts that a variable is of a given type.
*
Expand Down
17 changes: 17 additions & 0 deletions src/Framework/Assert/Functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -1837,6 +1837,23 @@ function assertNotSame(mixed $expected, mixed $actual, string $message = ''): vo
}
}

if (!function_exists('PHPUnit\Framework\assertSameDictionaryKeysValues')) {
/**
* Assert that two arrays have the same keys and values for those keys.
* The order of the keys is ignored.
*
* @throws ExpectationFailedException
*
* @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit
*
* @see Assert::assertSameDictionaryKeysValues
*/
function assertSameDictionaryKeysValues(mixed $expected, mixed $actual, string $message = ''): void
{
Assert::assertSameDictionaryKeysValues(...func_get_args());
}
}

if (!function_exists('PHPUnit\Framework\assertInstanceOf')) {
/**
* Asserts that a variable is of a given type.
Expand Down
294 changes: 294 additions & 0 deletions src/Framework/Constraint/Dictionary/IsIdenticalKeysValues.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,294 @@
<?php

declare(strict_types=1);
/*
* This file is part of PHPUnit.
*
* (c) Sebastian Bergmann <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace PHPUnit\Framework\Constraint\Dictionary;

use function array_key_exists;
use function gettype;
use function in_array;
use function is_array;
use function is_object;
use function sprintf;
use function str_replace;
use function substr_replace;
use function trim;
use PHPUnit\Framework\Constraint\Constraint;
use PHPUnit\Framework\ExpectationFailedException;
use SebastianBergmann\Comparator\ComparisonFailure;
use SebastianBergmann\Exporter\Exporter;

final class IsIdenticalKeysValues extends Constraint
{
private readonly mixed $value;

public function __construct(mixed $value)
{
$this->value = $value;
}

/**
* Evaluates the constraint for parameter $other.
*
* If $returnResult is set to false (the default), an exception is thrown
* in case of a failure. null is returned otherwise.
*
* If $returnResult is true, the result of the evaluation is returned as
* a boolean value instead: true in case of success, false in case of a
* failure.
*
* @throws ExpectationFailedException
*/
public function evaluate(mixed $other, string $description = '', bool $returnResult = false): bool
{
// cribbed from `src/Framework/Constraint/Equality/IsEqualCanonicalizing.php`
try {
if (!is_array($this->value)) {
throw new ComparisonFailure(
gettype([]),
gettype($this->value),
(new Exporter)->export(gettype([])),
(new Exporter)->export(gettype($this->value)),
sprintf(
'%s is not an instance of %s',
(new Exporter)->export(gettype($this->value)),
(new Exporter)->export(gettype([])),
),
);
}

if (!is_array($other)) {
throw new ComparisonFailure(
gettype([]),
gettype($other),
(new Exporter)->export(gettype([])),
(new Exporter)->export(gettype($other)),
sprintf(
'%s is not an instance of %s',
(new Exporter)->export(gettype($other)),
(new Exporter)->export(gettype([])),
),
);
}

$this->compareDictionary($this->value, $other);
} catch (ComparisonFailure $f) {
if ($returnResult) {
return false;
}

throw new ExpectationFailedException(
trim($description . "\n" . $f->getMessage()),
$f,
);
}

return true;
}

/**
* Returns a string representation of the constraint.
*/
public function toString(): string
{
return 'is identical to ' . (new Exporter)->export($this->value);
}

/**
* cribbed from `vendor/sebastian/comparator/src/ArrayComparator.php`
* This potentially should be a dictionarycomparator or type-strict arraycomparator.
*/
/** @phpstan-ignore missingType.iterableValue, missingType.iterableValue, missingType.iterableValue */
private function compareDictionary(array $expected, array $actual, array &$processed = []): void
{
$remaining = $actual;
$actualAsString = "Array (\n";
$expectedAsString = "Array (\n";
$equal = true;
$exporter = new Exporter;

foreach ($expected as $key => $value) {
unset($remaining[$key]);

if (!array_key_exists($key, $actual)) {
$expectedAsString .= sprintf(
" %s => %s\n",
$exporter->export($key),
$exporter->shortenedExport($value),
);
$equal = false;

continue;
}

try {
switch (true) {
// type mismatch, expected array, got something else
case is_array($value) && !is_array($actual[$key]):
throw new ComparisonFailure(
$value,
$actual[$key],
$exporter->export($value),
$exporter->export($actual[$key]),
);

// expected array, got array
case is_array($value) && is_array($actual[$key]):
$this->compareDictionary($value, $actual[$key]);

break;

// type mismatch, expected object, got something else
case is_object($value) && !is_object($actual[$key]):
throw new ComparisonFailure(
$value,
$actual[$key],
$exporter->export($value),
$exporter->export($actual[$key]),
);

// type mismatch, expected object, got object
case is_object($value) && is_object($actual[$key]):
$this->compareObjects($value, $actual[$key], $processed);

break;

// both are not array, both are not objects, strict comparison check
default:
if ($value === $actual[$key]) {
continue 2;
}

throw new ComparisonFailure(
$value,
$actual[$key],
$exporter->export($value),
$exporter->export($actual[$key]),
);
}

$expectedAsString .= sprintf(
" %s => %s\n",
$exporter->export($key),
$exporter->shortenedExport($value),
);
$actualAsString .= sprintf(
" %s => %s\n",
$exporter->export($key),
$exporter->shortenedExport($actual[$key]),
);
} catch (ComparisonFailure $e) {
$expectedAsString .= sprintf(
" %s => %s\n",
$exporter->export($key),
$e->getExpectedAsString() !== '' ? $this->indent(
$e->getExpectedAsString(),
) : $exporter->shortenedExport($e->getExpected()),
);
$actualAsString .= sprintf(
" %s => %s\n",
$exporter->export($key),
$e->getActualAsString() !== '' ? $this->indent(
$e->getActualAsString(),
) : $exporter->shortenedExport($e->getActual()),
);
$equal = false;
}
}

foreach ($remaining as $key => $value) {
$actualAsString .= sprintf(
" %s => %s\n",
$exporter->export($key),
$exporter->shortenedExport($value),
);
$equal = false;
}

$expectedAsString .= ')';
$actualAsString .= ')';

if (!$equal) {
throw new ComparisonFailure(
$expected,
$actual,
$expectedAsString,
$actualAsString,
'Failed asserting that two arrays are equal.',
);
}
}

/**
* cribbed from `vendor/sebastian/comparator/src/ObjectComparator.php`
* this potentially should be a type-strict objectcomparator.
*/
/** @phpstan-ignore missingType.iterableValue */
private function compareObjects(object $expected, object $actual, array &$processed = []): void
{
if ($actual::class !== $expected::class) {
$exporter = new Exporter;

throw new ComparisonFailure(
$expected,
$actual,
$exporter->export($expected),
$exporter->export($actual),
sprintf(
'%s is not instance of expected class "%s".',
$exporter->export($actual),
$expected::class,
),
);
}

// don't compare twice to allow for cyclic dependencies
if (in_array([$actual, $expected], $processed, true) ||
in_array([$expected, $actual], $processed, true)) {
return;
}

$processed[] = [$actual, $expected];

if ($actual === $expected) {
return;
}

try {
$this->compareDictionary($this->toArray($expected), $this->toArray($actual), $processed);
} catch (ComparisonFailure $e) {
throw new ComparisonFailure(
$expected,
$actual,
// replace "Array" with "MyClass object"
substr_replace($e->getExpectedAsString(), $expected::class . ' Object', 0, 5),
substr_replace($e->getActualAsString(), $actual::class . ' Object', 0, 5),
'Failed asserting that two objects are equal.',
);
}
}

/**
* cribbed from `vendor/sebastian/comparator/src/ObjectComparator.php`.
*/
/** @phpstan-ignore missingType.iterableValue */
private function toArray(object $object): array
{
return (new Exporter)->toArray($object);
}

/**
* cribbed from `vendor/sebastian/comparator/src/ArrayComparator.php`.
*/
private function indent(string $lines): string
{
return trim(str_replace("\n", "\n ", $lines));
}
}
Loading
Loading