assertObjectEquals() and Custom Comparators in PHPUnit
AI generated
@test
assert
PHPUnit · Object Comparison · PHP
assertObjectEquals()
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.

14 min read assertObjectEquals Comparator classes Value objects

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.

11. FAQ: assertObjectEquals(): The Key Facts at a Glance

1When does classic assertEquals() no longer suffice for object comparisons?
As soon as an object has its own business definition of equality that cannot be captured by pure property-by-property comparison, for instance different internal representations of the same value.
2How does assertObjectEquals() work technically?
It calls a named method, by convention equals(), on the expected object and passes the actual object as an argument, instead of automatically comparing properties.
3Can the method name used for comparison in assertObjectEquals() be changed?
Yes, an optional third parameter lets you specify a method name other than equals().
4Why are failure messages from assertObjectEquals() less detailed?
Because PHPUnit no longer automatically knows which individual property differs, since the entire comparison logic is encapsulated in the class's equals() method.
5What is a custom comparator in PHPUnit?
A class implementing the ComparatorInterface that applies globally to assertEquals() calls for certain type combinations once registered through the ComparatorFactory.
6Where do you register a custom comparator?
Usually in a bootstrap.php file or in setUpBeforeClass() of a shared test base class, so the registration applies to the entire suite.
7What is the risk of global comparators?
They can produce surprising effects when a test accidentally expects business equality instead of structural equality, and should therefore be documented for the team.
8When is assertObjectEquals() better suited than a global comparator?
For isolated comparisons that occur in only a few places in the test suite, because every test case then explicitly shows that business equality is being checked.
9What common mistake undermines the purpose of assertObjectEquals()?
An equals() method implemented too superficially, comparing only a single identifier instead of all business-relevant fields.
10Does equals() need to be implemented symmetrically for assertObjectEquals()?
It is recommended, since the method is only called on the expected object. Symmetric equality should additionally be verified with a dedicated test.