Custom Assertions: eigene Assertions für Domänenobjekte schreiben
AI generated
@test
assert
PHPUnit · Custom Assertions · Domain Testing
Custom Assertions für Domänenobjekte
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.

15 Min. Lesezeit Custom Assertions Domänenobjekte testen

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.

11. FAQ: Custom Assertions: Das Wichtigste auf einen Blick

1Muss eine Custom Assertion immer eine eigene Constraint-Klasse sein?
Nein, für einfache Fälle reicht eine statische Hilfsmethode mit einem aussagekräftigen Fehlermeldungstext völlig aus. Eine eigene Constraint-Klasse lohnt sich erst, wenn strukturierte Diffs oder häufige Wiederverwendung über assertThat gewünscht sind.
2Wo sollten Custom Assertions in einem Projekt liegen?
Üblich ist ein eigener Namespace innerhalb des Testverzeichnisses, etwa Tests\Constraint für Constraint-Klassen und Tests\Assertions für die zugehörigen Traits, getrennt von den eigentlichen Testklassen.
3Kann ich Custom Assertions mit den eingebauten PHPUnit-Assertions kombinieren?
Ja, es gibt keinen Konflikt, generische Assertions wie assertCount und domänenspezifische Assertions wie assertMoneyEquals ergänzen sich problemlos innerhalb derselben Testmethode.
4Erkennt PHPUnit Custom Assertions automatisch für die Test-Coverage?
Ja, sofern die Assertion letztlich intern eine der PHPUnit-eigenen assert-Methoden aufruft, zählt der Aufruf ganz normal zur Assertion-Anzahl im Testreport, es entsteht kein Unterschied zur direkten Nutzung.
5Was ist der Unterschied zwischen matches() und failureDescription() in einer Constraint?
matches() enthält die eigentliche Vergleichslogik und gibt einen Boolean zurück, failureDescription() wird nur bei einem Fehlschlag aufgerufen und erzeugt den lesbaren Fehlertext, der dem Entwickler angezeigt wird.
6Lohnt sich eine Custom Assertion für ein Objekt, das nur in einem einzigen Test vorkommt?
In der Regel nicht, hier überwiegt der Erstellungsaufwand den Nutzen. Eine einfache assertEquals-Kette oder ein direkter Feldvergleich bleibt für einmalige Fälle die pragmatischere Wahl.
7Sollten Custom Assertions auch negative Prüfungen wie assertMoneyNotEquals anbieten?
Ja, das Fehlen des negativen Gegenstücks führt oft dazu, dass Entwickler bei negativen Prüfungen wieder zu generischen Assertions ohne fachlichen Kontext zurückgreifen, was den ursprünglichen Lesbarkeitsgewinn zunichtemacht.
8Wie strukturiere ich viele Custom Assertions ohne eine überladene Basisklasse?
Traits statt einer wachsenden Basisklasse sind der bewährte Ansatz, jede Testklasse bindet nur die thematisch passenden Traits ein, statt Zugriff auf alle jemals geschriebenen Assertions zu erben.
9Funktionieren Custom Assertions auch mit typisierten Objekten, die kein equals() haben?
Ja, die Vergleichslogik in matches() kann beliebig implementiert werden, etwa über Reflection auf einzelne Eigenschaften, wenn das Domänenobjekt keine eigene equals()-Methode bereitstellt.
10Verändern Custom Assertions das Verhalten von assertThat bei geschachtelten Constraints?
Nein, eigene Constraints lassen sich genauso mit logischen Kombinatoren wie logicalAnd oder logicalOr verknüpfen wie eingebaute PHPUnit-Constraints, solange sie korrekt von der Basis-Constraint-Klasse erben.