Eigene Comparators für komplexe Wertobjekte in PHPUnit
Der automatische Property-für-Property-Vergleich von assertEquals() scheitert bei Wertobjekten mit eigener fachlicher Gleichheitsdefinition. assertObjectEquals() und eigene Comparator-Klassen lösen dieses Problem, indem sie die tatsächliche Vergleichslogik der Klasse selbst nutzen.
Inhaltsverzeichnis
- 1. Wo der automatische Objektvergleich an seine Grenzen stößt
- 2. Wie assertObjectEquals() funktioniert
- 3. assertObjectEquals() im Test einsetzen
- 4. Fehlermeldungen bei fehlschlagenden Vergleichen
- 5. Das ComparatorInterface für globale Vergleichslogik
- 6. Einen eigenen Comparator registrieren
- 7. assertObjectEquals() oder eigener Comparator: die Entscheidung
- 8. Häufige Fehler im Umgang mit Objektvergleichen
- 9. Fazit: Fachliche Gleichheit statt struktureller Zufälligkeit
- 10. Zusammenfassung
- 11. FAQ
1. Wo der automatische Objektvergleich an seine Grenzen stößt
PHPUnits assertEquals() vergleicht zwei Objekte standardmäßig rekursiv Property für Property. Für einfache Datenklassen ohne eigene fachliche Regeln funktioniert das zuverlässig. Sobald ein Wertobjekt aber eine eigene Gleichheitsdefinition hat, etwa ein Geldbetrag, der Beträge in unterschiedlichen, aber wertgleichen internen Repräsentationen speichert, liefert der reine Property-Vergleich falsche Ergebnisse.
Ein typisches Beispiel ist eine Geldklasse, die den Betrag intern als Cent-Integer speichert, aber zwei Instanzen mit unterschiedlicher interner Rundung als fachlich gleich betrachten soll, solange sie im sichtbaren Nachkommastellenbereich übereinstimmen. Der automatische Property-Vergleich von assertEquals() würde solche Instanzen als ungleich melden, obwohl die Klasse selbst über eine equals()-Methode klar definiert, dass sie gleich sein sollen. Ähnliche Fälle treten bei Wertobjekten mit einem zwischengespeicherten, aber fachlich irrelevanten Property auf, etwa einem intern mitgeführten Erzeugungszeitstempel, der bei zwei ansonsten identischen Instanzen minimal abweichen kann, ohne dass dies die fachliche Gleichheit beeinträchtigen soll.
2. Wie assertObjectEquals() funktioniert
Seit PHPUnit 10 gibt es mit assertObjectEquals() eine gezielte Lösung für genau dieses Problem. Statt Properties automatisch zu vergleichen, ruft die Assertion eine benannte Methode auf dem erwarteten Objekt auf und übergibt ihr das tatsächliche Objekt als Argument. Per Konvention heißt diese Methode equals(), kann aber über einen optionalen dritten Parameter auch anders benannt werden.
Diese Umkehrung ist bewusst gewählt: Die Vergleichslogik lebt direkt in der zu testenden Klasse selbst, statt in einer separaten Testinfrastruktur nachgebildet zu werden. Das hat den Vorteil, dass die Gleichheitsdefinition nur an einer einzigen Stelle im Produktivcode gepflegt wird und Tests automatisch von Änderungen an dieser Definition profitieren, ohne selbst angepasst werden zu müssen.
<?php
declare(strict_types=1);
namespace App\Money;
/**
* Unveraenderlicher Geldbetrag mit fachlicher Gleichheitsdefinition.
*/
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. assertObjectEquals() im Test einsetzen
Im Test wird assertObjectEquals() genauso aufgerufen wie assertEquals(), mit erwartetem und tatsächlichem Wert als erste beiden Argumente. Der Unterschied liegt ausschließlich darin, welche Vergleichslogik im Hintergrund läuft: Statt aller Properties wird jetzt exakt die equals()-Methode der erwarteten Instanz befragt.
Wichtig ist, dass die Methode auf dem erwarteten Objekt aufgerufen wird, nicht auf dem tatsächlichen. Das spielt eine Rolle, sobald die Vergleichslogik nicht vollständig symmetrisch implementiert ist, was in der Praxis zwar selten, aber nicht ausgeschlossen ist. Wer symmetrische Gleichheit garantieren will, sollte das explizit mit einem eigenen Test für equals() selbst absichern.
<?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. Fehlermeldungen bei fehlschlagenden Vergleichen
Ein Nachteil von assertObjectEquals() gegenüber dem klassischen Property-Vergleich ist, dass die Fehlermeldung bei einem Fehlschlag standardmäßig weniger detailliert ausfällt, da PHPUnit nicht mehr automatisch weiß, welche einzelne Property abweicht. Die Meldung zeigt lediglich, dass equals() false zurückgegeben hat, nicht aber welcher konkrete Wert dafür verantwortlich ist.
Um das auszugleichen, lohnt es sich, die equals()-Methode selbst mit einer aussagekräftigen __toString()-Implementierung der beteiligten Klasse zu kombinieren, damit PHPUnit beide Objekte zumindest lesbar in der Fehlermeldung ausgibt. Alternativ kann man in komplexeren Fällen zusätzliche, gezielte Assertions auf einzelnen Properties ergänzen, wenn ein Testfall gezielt eine bestimmte Abweichung nachweisen soll. Gerade in größeren Teams lohnt es sich, diese Konvention einmal schriftlich festzuhalten, damit nicht jeder Entwickler die Kombination aus equals() und __toString() für sich neu entdecken muss.
5. Das ComparatorInterface für globale Vergleichslogik
Neben assertObjectEquals(), das die Vergleichslogik pro Aufruf und pro Klasse über eine Methode einbindet, bietet PHPUnit mit dem SebastianBergmann\Comparator-Paket eine zweite, globalere Lösung: eigene Comparator-Klassen, die für assertEquals() selbst greifen, ohne dass der Testcode explizit assertObjectEquals() aufrufen muss.
Ein eigener Comparator implementiert SebastianBergmann\Comparator\Comparator mit zwei zentralen Methoden: accepts() entscheidet, für welche Typenkombination der Comparator zuständig ist, und assertEquals() enthält die eigentliche Vergleichslogik inklusive einer aussagekräftigen Fehlermeldung im Falle einer Abweichung.
<?php
declare(strict_types=1);
namespace Tests\Support\Comparator;
use App\Money\Money;
use SebastianBergmann\Comparator\Comparator;
use SebastianBergmann\Comparator\ComparisonFailure;
/**
* Globaler Comparator fuer Money-Objekte, greift automatisch bei 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. Einen eigenen Comparator registrieren
Damit PHPUnit den eigenen Comparator tatsächlich verwendet, muss er über die ComparatorFactory registriert werden, üblicherweise in einer bootstrap.php-Datei oder in der setUpBeforeClass()-Methode einer gemeinsamen Test-Basisklasse. Nach der Registrierung greift der Comparator automatisch für jeden assertEquals()-Aufruf, dessen Typen von accepts() akzeptiert werden. Die Registrierung selbst muss dabei nur einmal pro Testlauf erfolgen, unabhängig davon, wie viele einzelne Testklassen später tatsächlich Money-Objekte vergleichen.
Diese globale Wirkung ist gleichzeitig Stärke und Risiko: Sie erspart es, in jedem einzelnen Test explizit assertObjectEquals() zu verwenden, kann aber auch überraschende Effekte haben, wenn ein Test versehentlich eine fachliche statt einer strukturellen Gleichheit erwartet. Für neue Projekte ist deshalb assertObjectEquals() oft die vorhersehbarere Wahl, während globale Comparators sich für große, bereits bestehende Testsuiten mit vielen Aufrufstellen lohnen.
<?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() oder eigener Comparator: die Entscheidung
Für Wertobjekte, die nur an wenigen Stellen der Testsuite verglichen werden, ist assertObjectEquals() in der Regel die einfachere und lokal nachvollziehbarere Wahl, weil jeder Testfall explizit zeigt, dass hier eine fachliche statt einer strukturellen Gleichheit geprüft wird. Der Leser eines einzelnen Tests muss dafür nicht wissen, dass irgendwo eine globale Comparator-Registrierung existiert.
Für zentrale Domänen-Wertobjekte wie Geldbeträge, Prozentwerte oder Adressen, die in Hunderten von Tests über die gesamte Suite verglichen werden, lohnt sich dagegen der globale Comparator, weil er verhindert, dass in jedem einzelnen Test manuell an assertObjectEquals() statt an assertEquals() gedacht werden muss, was in der Praxis leicht vergessen wird. In gewachsenen Projekten ist außerdem eine schrittweise Migration denkbar: Man beginnt mit assertObjectEquals() an den kritischsten Stellen und führt erst dann einen globalen Comparator ein, wenn sich die Zahl der betroffenen Vergleiche als tatsächlich groß genug erweist, um den zusätzlichen Registrierungsaufwand zu rechtfertigen.
8. Häufige Fehler im Umgang mit Objektvergleichen
Ein häufiger Fehler ist, die equals()-Methode für assertObjectEquals() nur oberflächlich zu implementieren, etwa indem sie lediglich einen einzelnen Identifikator statt aller fachlich relevanten Felder vergleicht. Dadurch werden Tests grün, obwohl sich fachlich relevante Werte tatsächlich unterscheiden, was den eigentlichen Zweck des Objektvergleichs untergräbt.
Ein zweiter Fehler ist, einen globalen Comparator zu registrieren, ohne im Team zu dokumentieren, dass er existiert. Neue Teammitglieder wundern sich dann, warum ein scheinbar einfacher assertEquals()-Aufruf zwischen zwei offensichtlich unterschiedlichen Objekten grün wird, weil ihnen die fachliche Gleichheitsdefinition hinter dem Comparator nicht bekannt ist.
9. Fazit: Fachliche Gleichheit statt struktureller Zufälligkeit
assertObjectEquals() und eigene Comparator-Klassen lösen ein Problem, das in jeder Codebasis mit echten Wertobjekten früher oder später auftritt: Der reine Property-für-Property-Vergleich reicht nicht aus, sobald ein Objekt eine eigene, fachlich begründete Gleichheitsdefinition hat. Für Magento- und andere PHP-Projekte mit Geldbeträgen, Adressen oder ähnlichen Wertobjekten ist der Umstieg fast immer lohnenswert.
Welcher der beiden Wege sich besser eignet, hängt von der Zahl der betroffenen Testfälle ab: punktuelle Vergleiche profitieren von der Lesbarkeit einzelner assertObjectEquals()-Aufrufe, während breit gestreute Vergleiche in großen Suiten von einem einmal registrierten, global wirksamen Comparator profitieren.
| Ansatz | Wo definiert | Reichweite | Einsatzgebiet |
|---|---|---|---|
| assertEquals() (Standard) | Automatisch, Property für Property | Jeder Vergleich | Einfache Datenklassen ohne eigene Gleichheitslogik |
| assertObjectEquals() | equals()-Methode auf der Klasse | Pro Testaufruf explizit | Punktuelle Vergleiche von Wertobjekten |
| Eigener Comparator | ComparatorInterface-Implementierung | Global für alle assertEquals()-Aufrufe | Zentrale, häufig verglichene Domänen-Wertobjekte |
| Manuelle Property-Assertions | Im Testcode selbst | Pro Testfall | Wenn ein Fehlschlag exakt eine Property nachweisen soll |
Mironsoft
Testautomatisierung, Magento-Qualitätssicherung und CI-Integration
Tests, die echte Fehler finden statt nur grün zu leuchten?
Wir prüfen bestehende PHPUnit-Suiten auf Implementierungsdetail-Tests, flaky Tests und fehlende Coverage an kritischen Stellen und bauen daraus eine Teststrategie, die bei jedem Magento-Update wirklich Sicherheit gibt.
Test-Audit
Bestehende Suiten auf Mocking-Antipatterns und blinde Flecken prüfen.
Teststrategie
Unit-, Integrations- und MFTF-Tests sinnvoll für Magento-Projekte kombinieren.
CI-Integration
Schnelle, zuverlässige Testläufe in GitLab CI oder GitHub Actions einrichten.
10. Zusammenfassung
assertObjectEquals(): Das Wichtigste auf einen Blick
Kernproblem
Automatischer Property-Vergleich scheitert bei Wertobjekten mit eigener fachlicher Gleichheitsdefinition.
assertObjectEquals()
Ruft die equals()-Methode des erwarteten Objekts auf, statt Properties automatisch zu vergleichen.
Eigener Comparator
Wirkt global für alle assertEquals()-Aufrufe, muss aber über die ComparatorFactory registriert werden.
Entscheidung
Punktuelle Vergleiche: assertObjectEquals(). Breit gestreute Vergleiche in großen Suiten: globaler Comparator.