Warum assertMoneyEquals lesbarer ist als eine Kette aus assertEquals-Aufrufen
Eine Kette aus mehreren assertEquals-Aufrufen, die einzelne Felder eines Domänenobjekts vergleichen, ist mühsam zu schreiben und liefert bei einem Fehlschlag nur bruchstückhafte Information. Eigene, domänenspezifische Assertions wie assertMoneyEquals oder assertValidOrderState bündeln diese Vergleiche an einer Stelle und liefern Fehlermeldungen, die sofort zeigen, was fachlich falsch war. Dieser Artikel zeigt, wie solche Assertions entstehen und wann sich der Aufwand lohnt.
Inhaltsverzeichnis
- 1. Das Problem mit generischen assertEquals-Ketten
- 2. Die erste eigene Assertion: eine einfache Hilfsmethode
- 3. Eine echte PHPUnit-Constraint für präzise Fehlermeldungen
- 4. Die Assertion-Methode als sprechende Fassade vor der Constraint
- 5. assertValidOrderState: Custom Assertions für zusammengesetzte Zustände
- 6. Custom Assertions auch für den negativen Fall bereitstellen
- 7. Wiederverwendung über Traits statt Vererbung
- 8. Wann sich der Aufwand für Custom Assertions wirklich lohnt
- 9. Fazit: Investition in Lesbarkeit zahlt sich mit jedem Testlauf aus
- 10. Zusammenfassung
- 11. FAQ
1. Das Problem mit generischen assertEquals-Ketten
Domänenobjekte wie ein Money-Value-Object oder eine Order-Entität bestehen oft aus mehreren Feldern, die gemeinsam einen fachlichen Zustand beschreiben. Ein Money-Objekt hat typischerweise einen Betrag und eine Währung, eine Order einen Status, eine Kundennummer und eine Liste von Positionen. Ein naiver Test vergleicht jedes dieser Felder einzeln mit assertEquals oder assertSame, was schnell zu fünf oder sechs Assertion-Zeilen für einen einzigen fachlichen Vergleich führt.
Das eigentliche Problem zeigt sich erst beim Fehlschlag: Schlägt eine dieser Zeilen fehl, meldet PHPUnit lediglich, dass zwei einzelne Skalarwerte nicht übereinstimmen, etwa 'Failed asserting that 1900 matches expected 1990'. Ohne Kontext, welches Feld das war und zu welchem Objekt es gehörte, muss der Entwickler den Testcode öffnen, um die Bedeutung dieser Zahl zu rekonstruieren, ein unnötiger Umweg bei einer eigentlich einfachen Diagnose.
2. Die erste eigene Assertion: eine einfache Hilfsmethode
Der einfachste Einstieg ist eine protected Hilfsmethode in einer gemeinsamen Basis-Testklasse, die alle relevanten Felder eines Domänenobjekts vergleicht und bei Abweichung eine aussagekräftige Fehlermeldung wirft. PHPUnits eigene Assertion-Methoden wie assertSame akzeptieren als letztes Argument bereits eine optionale Fehlermeldung, diese lässt sich nutzen, um fachlichen Kontext mitzugeben, ohne gleich eine vollständige Constraint-Klasse zu bauen.
Diese einfache Variante ist ein guter erster Schritt, hat aber eine Einschränkung: Bei einem Fehlschlag zeigt PHPUnit nur die eine zusätzliche Nachricht, nicht aber automatisch, welches der verglichenen Felder tatsächlich abwich, wenn mehrere Felder in einer einzigen Methode geprüft werden. Für einfache Objekte mit wenigen Feldern reicht das oft aus, bei komplexeren Objekten lohnt sich der nächste Schritt, eine echte PHPUnit-Constraint.
<?php
declare(strict_types=1);
namespace Mironsoft\Tests;
use PHPUnit\Framework\TestCase;
abstract class DomainTestCase extends TestCase
{
/**
* Einfache Hilfsmethode: vergleicht Betrag und Waehrung eines Money-Objekts
* und liefert bei Abweichung eine fachlich verstaendliche Meldung.
*/
protected static function assertMoneyEquals(Money $expected, Money $actual, string $message = ''): void
{
$description = sprintf(
'Failed asserting that money %s %s equals expected %s %s',
$actual->getAmountInCents(),
$actual->getCurrency(),
$expected->getAmountInCents(),
$expected->getCurrency()
);
self::assertTrue($expected->equals($actual), $message !== '' ? $message : $description);
}
}
3. Eine echte PHPUnit-Constraint für präzise Fehlermeldungen
Der nächste Ausbauschritt ist eine eigene Klasse, die von PHPUnit\Framework\Constraint\Constraint erbt. Eine Constraint kapselt die Vergleichslogik in matches() und die Fehlermeldungsformatierung in failureDescription(), wodurch PHPUnit bei einem Fehlschlag automatisch den vollständigen erwarteten und tatsächlichen Zustand des Objekts anzeigen kann, inklusive einer sauberen Diff-Darstellung, wenn beide Objekte sich zu einer druckbaren Repräsentation exportieren lassen.
Diese Investition lohnt sich vor allem für Domänenobjekte, die in vielen Tests wiederholt verglichen werden, etwa in einer großen Testsuite für einen Checkout-Prozess. Einmal geschrieben, liefert die Constraint überall dieselbe hochwertige Fehlermeldung, ohne dass jede Testmethode ihre eigene Vergleichslogik neu erfindet, was gleichzeitig Code-Duplizierung reduziert und Konsistenz zwischen verschiedenen Testdateien sicherstellt.
<?php
declare(strict_types=1);
namespace Mironsoft\Tests\Constraint;
use PHPUnit\Framework\Constraint\Constraint;
final class IsSameMoney extends Constraint
{
public function __construct(private readonly Money $expected)
{
}
public function matches($other): bool
{
return $other instanceof Money && $this->expected->equals($other);
}
public function toString(): string
{
return sprintf('equals %s %s', $this->expected->getAmountInCents(), $this->expected->getCurrency());
}
protected function failureDescription($other): string
{
if (!$other instanceof Money) {
return 'value ' . $this->exporter()->export($other) . ' ' . $this->toString();
}
return sprintf(
'money %s %s %s',
$other->getAmountInCents(),
$other->getCurrency(),
$this->toString()
);
}
}
4. Die Assertion-Methode als sprechende Fassade vor der Constraint
Die Constraint-Klasse selbst wird selten direkt im Testcode verwendet, üblich ist eine schlanke statische Assertion-Methode, die die Constraint intern über assertThat() anwendet. Diese Fassade ist der Teil, der im eigentlichen Test aufgerufen wird, und ihr Name ist es, der die Lesbarkeit ausmacht, assertMoneyEquals($expected, $actual) liest sich wie eine natürliche Aussage über die Erwartung, ganz ohne technische Details der Constraint sichtbar zu machen.
Diese Trennung zwischen Constraint und Assertion-Fassade folgt demselben Muster, das PHPUnit intern für seine eigenen Assertions wie assertEquals nutzt, assertEquals ist letztlich auch nur eine dünne Fassade vor der IsEqual-Constraint. Wer diese Konvention übernimmt, baut Custom Assertions, die sich für andere Entwickler im Team genauso anfühlen wie die eingebauten PHPUnit-Assertions, was die Einstiegshürde deutlich senkt.
<?php
declare(strict_types=1);
namespace Mironsoft\Tests;
use Mironsoft\Tests\Constraint\IsSameMoney;
use PHPUnit\Framework\TestCase;
abstract class DomainTestCase extends TestCase
{
protected static function assertMoneyEquals(Money $expected, mixed $actual, string $message = ''): void
{
static::assertThat($actual, new IsSameMoney($expected), $message);
}
}
// Verwendung im eigentlichen Test:
final class InvoiceTest extends DomainTestCase
{
public function testeBerechnetGesamtsumme(): void
{
$invoice = Invoice::fromLines([100_00, 50_00]);
self::assertMoneyEquals(Money::fromCents(150_00, 'EUR'), $invoice->getTotal());
}
}
5. assertValidOrderState: Custom Assertions für zusammengesetzte Zustände
Bei komplexeren Domänenobjekten wie einer Order geht eine gute Custom Assertion über den reinen Feldvergleich hinaus und prüft eine fachliche Invariante als Ganzes. Eine assertValidOrderState-Assertion könnte etwa sicherstellen, dass die Summe aller Positionspreise mit dem Gesamtbetrag übereinstimmt, dass der Status zu den vorhandenen Zeitstempeln passt, und dass keine widersprüchlichen Zustände auftreten, alles in einer einzigen, sprechenden Methode statt verteilt über mehrere Testzeilen.
Solche zusammengesetzten Assertions sind besonders wertvoll, weil sie fachliches Wissen an einer zentralen Stelle bündeln, statt es in jeder Testmethode neu zu implementieren. Ändert sich die Geschäftsregel, was einen gültigen Bestellzustand ausmacht, muss nur die Assertion angepasst werden, nicht jeder einzelne Test, der diese Regel implizit prüft, ein klarer Wartbarkeitsvorteil gegenüber verstreuten Einzelvergleichen.
<?php
declare(strict_types=1);
namespace Mironsoft\Tests;
use PHPUnit\Framework\Assert;
trait OrderAssertionsTrait
{
protected static function assertValidOrderState(Order $order): void
{
$sumOfLines = array_sum(array_map(
static fn (OrderLine $line): int => $line->getTotalInCents(),
$order->getLines()
));
Assert::assertSame(
$order->getTotalInCents(),
$sumOfLines,
sprintf(
'Order total %d does not match the sum of its lines %d',
$order->getTotalInCents(),
$sumOfLines
)
);
Assert::assertTrue(
$order->getStatus()->isConsistentWithTimestamps($order->getStatusHistory()),
'Order status is inconsistent with its recorded status history'
);
}
}
6. Custom Assertions auch für den negativen Fall bereitstellen
Eine oft vergessene Ergänzung ist das Gegenstück zur positiven Assertion, etwa assertOrderIsInvalid oder assertMoneyNotEquals. Ohne diese Ergänzung greifen Entwickler bei negativen Prüfungen häufig zu assertFalse($order->isValid()), was denselben Verlust an fachlichem Kontext mit sich bringt wie die ursprüngliche assertEquals-Kette, die die Custom Assertion eigentlich vermeiden sollte.
Beim Bau des negativen Gegenstücks lohnt sich besondere Sorgfalt bei der Fehlermeldung: Sie sollte nicht einfach die positive Meldung negieren, sondern erklären, warum ein eigentlich ungültiger Zustand fälschlich als gültig erkannt wurde, das ist die Information, die bei der Fehlersuche tatsächlich weiterhilft, insbesondere wenn die Assertion in einem Regressionstest für einen früheren Produktionsfehler eingesetzt wird.
7. Wiederverwendung über Traits statt Vererbung
Statt alle Custom Assertions in einer einzigen, wachsenden Basisklasse zu sammeln, bewährt sich eine Aufteilung in themenbezogene Traits, etwa MoneyAssertionsTrait, OrderAssertionsTrait und CustomerAssertionsTrait, die jede Testklasse gezielt einbindet. Dieser Ansatz vermeidet das klassische Problem einer aufgeblähten Basisklasse, die irgendwann Dutzende fachfremde Assertions enthält, von denen die meisten Testklassen nur einen Bruchteil tatsächlich benötigen.
Traits haben gegenüber einer tiefen Vererbungshierarchie außerdem den Vorteil, dass eine Testklasse mehrere thematische Assertion-Pakete gleichzeitig kombinieren kann, ohne in ein Diamond-Problem oder eine unübersichtliche mehrstufige Basisklassen-Kette zu geraten, was besonders bei Integrationstests hilfreich ist, die mehrere Domänenbereiche gleichzeitig berühren.
8. Wann sich der Aufwand für Custom Assertions wirklich lohnt
Nicht jede Testklasse braucht eigene Assertions, der Aufwand für eine vollständige Constraint-Klasse mit sauberer Fehlermeldung ist real und lohnt sich erst ab einer bestimmten Wiederverwendungsrate. Als Faustregel gilt: Wird derselbe fachliche Vergleich in mehr als drei oder vier Testmethoden wiederholt, überwiegt der Nutzen der Custom Assertion deutlich den einmaligen Erstellungsaufwand.
Für einmalige, sehr spezifische Vergleiche in einem einzelnen Test bleibt eine einfache assertEquals-Kette völlig legitim, hier würde eine eigene Constraint-Klasse reinen Overhead ohne echten Wiederverwendungsgewinn erzeugen. Die Kunst liegt darin, genau die Domänenobjekte zu identifizieren, die in der gesamten Testsuite immer wieder auftauchen, Value Objects und zentrale Entitäten sind dabei meist die besten Kandidaten.
9. Fazit: Investition in Lesbarkeit zahlt sich mit jedem Testlauf aus
Custom Assertions sind kein Selbstzweck, sondern ein gezieltes Werkzeug gegen zwei konkrete Probleme: repetitive Vergleichsketten im Testcode und kryptische Fehlermeldungen bei einem Fehlschlag. Richtig eingesetzt, machen sie Tests lesbarer, weil sie fachliche Absicht statt technischer Feldvergleiche ausdrücken, und beschleunigen die Fehlersuche, weil die Meldung sofort den fachlichen Kontext liefert.
Der Einstieg gelingt schrittweise: zunächst eine einfache Hilfsmethode mit Fehlermeldungstext, bei häufiger Wiederverwendung der Ausbau zu einer echten Constraint-Klasse mit strukturierter Fehlerbeschreibung. Die folgende Tabelle vergleicht die drei vorgestellten Ansätze entlang der wichtigsten Kriterien.
| Ansatz | Aufwand | Fehlermeldungsqualität | Wiederverwendbarkeit |
|---|---|---|---|
| Generische assertEquals-Kette | Keiner | Gering, nur Skalarwerte | Keine |
| Einfache Hilfsmethode mit Text | Niedrig | Mittel, ein Satz Kontext | Innerhalb der Basisklasse |
| Eigene Constraint-Klasse | Mittel bis hoch | Hoch, strukturierte Beschreibung | Suite-weit über assertThat |
| Trait mit mehreren Assertions | Mittel | Hoch | Gezielt pro Testklasse einbindbar |
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
Custom Assertions: Das Wichtigste auf einen Blick
Kernidee
Domänenspezifische Assertions bündeln Feldvergleiche in einer sprechenden Methode statt in einer assertEquals-Kette.
Größter Nutzen
Fehlermeldungen zeigen sofort den fachlichen Kontext statt nur abweichender Skalarwerte.
Technischer Kern
Eigene Constraint-Klassen erben von PHPUnit\Framework\Constraint\Constraint für strukturierte Diffs.
Faustregel
Ab drei bis vier Wiederholungen desselben Vergleichs überwiegt der Nutzen den Erstellungsaufwand.