Custom comparators for complex value objects in PHPUnit
The automatic property-by-property comparison of assertEquals() fails for value objects with their own business definition of equality. assertObjectEquals() and custom comparator classes solve this by using the class's own real comparison logic.
Table of Contents
- 1. Where automatic object comparison hits its limits
- 2. How assertObjectEquals() works
- 3. Using assertObjectEquals() in a test
- 4. Failure messages for failing comparisons
- 5. The ComparatorInterface for global comparison logic
- 6. Registering a custom comparator
- 7. assertObjectEquals() or a custom comparator: making the call
- 8. Common mistakes when working with object comparisons
- 9. Conclusion: business equality instead of structural coincidence
- 10. Summary
- 11. FAQ
1. Where automatic object comparison hits its limits
PHPUnit's assertEquals() compares two objects recursively, property by property, by default. For simple data classes without their own business rules, that works reliably. But as soon as a value object has its own definition of equality, for instance a monetary amount storing values in different but value-equal internal representations, plain property comparison produces wrong results.
A typical example is a money class that stores the amount internally as a cent integer, but should treat two instances with different internal rounding as business-equal as long as they match within the visible decimal range. The automatic property comparison of assertEquals() would report such instances as unequal, even though the class itself clearly defines through an equals() method that they should be considered equal.
2. How assertObjectEquals() works
Since PHPUnit 10, assertObjectEquals() offers a targeted solution to exactly this problem. Instead of automatically comparing properties, the assertion calls a named method on the expected object and passes the actual object as its argument. By convention this method is called equals(), but it can also be named differently via an optional third parameter.
This inversion is deliberate: the comparison logic lives directly inside the class under test itself, rather than being replicated in separate test infrastructure. That has the advantage that the equality definition is maintained in exactly one place in production code, and tests automatically benefit from changes to that definition without needing to be adjusted themselves.
<?php
declare(strict_types=1);
namespace App\Money;
/**
* Immutable monetary amount with a business definition of equality.
*/
final class Money
{
private function __construct(
private readonly int $cents,
private readonly string $currency,
) {
}
public static function fromCents(int $cents, string $currency): self
{
return new self($cents, $currency);
}
public function equals(self $other): bool
{
return $this->cents === $other->cents
&& $this->currency === $other->currency;
}
public function cents(): int
{
return $this->cents;
}
}
3. Using assertObjectEquals() in a test
In a test, assertObjectEquals() is called just like assertEquals(), with the expected and actual value as the first two arguments. The difference lies solely in which comparison logic runs behind the scenes: instead of every property, it now queries exactly the equals() method of the expected instance.
It matters that the method is called on the expected object, not on the actual one. That plays a role once the comparison logic is not fully symmetric, which in practice is rare but not excluded. Anyone who wants to guarantee symmetric equality should verify that explicitly with a dedicated test for equals() itself.
<?php
declare(strict_types=1);
namespace Tests\Unit\Money;
use App\Money\Money;
use PHPUnit\Framework\TestCase;
final class MoneyTest extends TestCase
{
public function testTwoAmountsWithSameValueAreEqual(): void
{
$expected = Money::fromCents(1999, 'EUR');
$actual = Money::fromCents(1999, 'EUR');
self::assertObjectEquals($expected, $actual);
}
public function testDifferentCurrenciesAreNotEqual(): void
{
$expected = Money::fromCents(1999, 'EUR');
$actual = Money::fromCents(1999, 'USD');
self::assertFalse($expected->equals($actual));
}
}
4. Failure messages for failing comparisons
One downside of assertObjectEquals() compared to the classic property comparison is that the failure message is by default less detailed, since PHPUnit no longer automatically knows which individual property differs. The message merely shows that equals() returned false, not which concrete value is responsible.
To compensate, it is worth combining the equals() method with a meaningful __toString() implementation on the involved class, so PHPUnit at least prints both objects readably in the failure message. Alternatively, in more complex cases, additional targeted assertions on individual properties can be added when a test case needs to specifically prove a particular discrepancy.
5. The ComparatorInterface for global comparison logic
Besides assertObjectEquals(), which wires up comparison logic per call and per class via a method, PHPUnit offers a second, more global solution through the SebastianBergmann\Comparator package: custom comparator classes that kick in for assertEquals() itself, without the test code having to explicitly call assertObjectEquals().
A custom comparator implements SebastianBergmann\Comparator\Comparator with two central methods: accepts() decides which type combination the comparator is responsible for, and assertEquals() contains the actual comparison logic including a meaningful failure message in case of a mismatch.
<?php
declare(strict_types=1);
namespace Tests\Support\Comparator;
use App\Money\Money;
use SebastianBergmann\Comparator\Comparator;
use SebastianBergmann\Comparator\ComparisonFailure;
/**
* Global comparator for Money objects, kicks in automatically for assertEquals().
*/
final class MoneyComparator extends Comparator
{
public function accepts($expected, $actual): bool
{
return $expected instanceof Money && $actual instanceof Money;
}
public function assertEquals(
$expected,
$actual,
$delta = 0.0,
$canonicalize = false,
$ignoreCase = false,
array &$processed = []
): void {
if (!$expected->equals($actual)) {
throw new ComparisonFailure(
$expected,
$actual,
(string) $expected->cents(),
(string) $actual->cents(),
sprintf('Failed asserting that two Money values are equal (%d vs. %d cents).', $expected->cents(), $actual->cents()),
);
}
}
}
6. Registering a custom comparator
For PHPUnit to actually use the custom comparator, it must be registered through the ComparatorFactory, usually in a bootstrap.php file or in the setUpBeforeClass() method of a shared test base class. After registration, the comparator automatically kicks in for every assertEquals() call whose types are accepted by accepts().
This global effect is both its strength and its risk: it saves you from having to explicitly use assertObjectEquals() in every single test, but it can also produce surprising effects when a test accidentally expects business equality instead of structural equality. For new projects, assertObjectEquals() is therefore often the more predictable choice, while global comparators pay off for large, already existing test suites with many call sites.
<?php
declare(strict_types=1);
// tests/bootstrap.php
require __DIR__ . '/../vendor/autoload.php';
use SebastianBergmann\Comparator\Factory;
use Tests\Support\Comparator\MoneyComparator;
Factory::getInstance()->register(new MoneyComparator());
7. assertObjectEquals() or a custom comparator: making the call
For value objects compared in only a few places across the test suite, assertObjectEquals() is usually the simpler and more locally traceable choice, because every test case explicitly shows that business equality rather than structural equality is being checked here. The reader of a single test does not need to know that a global comparator registration exists somewhere.
For central domain value objects such as monetary amounts, percentages, or addresses that get compared in hundreds of tests across the entire suite, the global comparator pays off instead, because it prevents every single test from having to manually remember to use assertObjectEquals() instead of assertEquals(), which in practice is easy to forget.
8. Common mistakes when working with object comparisons
A common mistake is implementing the equals() method for assertObjectEquals() only superficially, for instance comparing just a single identifier instead of all business-relevant fields. That makes tests pass even though business-relevant values actually differ, undermining the very point of an object comparison.
A second mistake is registering a global comparator without documenting to the team that it exists. New team members then wonder why a seemingly simple assertEquals() call between two obviously different objects passes, because they are unaware of the business equality definition hidden behind the comparator.
9. Conclusion: business equality instead of structural coincidence
assertObjectEquals() and custom comparator classes solve a problem that arises sooner or later in any codebase with real value objects: pure property-by-property comparison is not enough once an object has its own, business-justified definition of equality. For Magento and other PHP projects with monetary amounts, addresses, or similar value objects, making the switch is almost always worthwhile.
Which of the two paths fits better depends on the number of affected test cases: isolated comparisons benefit from the readability of individual assertObjectEquals() calls, while widely spread comparisons across large suites benefit from a once-registered, globally effective comparator.
| Approach | Where defined | Scope | Use case |
|---|---|---|---|
| assertEquals() (default) | Automatic, property by property | Every comparison | Simple data classes without their own equality logic |
| assertObjectEquals() | equals() method on the class | Explicit per test call | Isolated comparisons of value objects |
| Custom comparator | ComparatorInterface implementation | Global for all assertEquals() calls | Central, frequently compared domain value objects |
| Manual property assertions | Inside the test code itself | Per test case | When a failure needs to prove exactly one property |
Mironsoft
Test automation, Magento quality assurance, and CI integration
Tests that catch real bugs instead of just turning green?
We review existing PHPUnit suites for implementation-detail tests, flaky tests, and missing coverage at critical points, then build a test strategy that provides real confidence with every Magento update.
Test Audit
Reviewing existing suites for mocking antipatterns and blind spots.
Test Strategy
Meaningfully combining unit, integration, and MFTF tests for Magento projects.
CI Integration
Setting up fast, reliable test runs in GitLab CI or GitHub Actions.
10. Summary
assertObjectEquals(): The Key Facts at a Glance
Core problem
Automatic property comparison fails for value objects with their own business definition of equality.
assertObjectEquals()
Calls the equals() method of the expected object instead of automatically comparing properties.
Custom comparator
Applies globally to all assertEquals() calls, but must be registered through the ComparatorFactory.
Decision
Isolated comparisons: assertObjectEquals(). Widely spread comparisons in large suites: global comparator.