@test
assert
PHPUnit · Assertions · PHP Testing · Clean Tests
Bessere PHPUnit-Assertions schreiben
Klarheit vor Cleverness

Wer assertEquals schreibt, wo assertSame gemeint ist, schreibt Tests, die bestehen, obwohl die Software fehlerhaft ist. Assertions sind keine Boilerplate – sie sind die Sprache, in der ein Test beschreibt, was korrekt bedeutet. Die richtige Wahl zwischen Gleichheit, Identität, Typ und Struktur entscheidet darüber, ob ein Test ein Sicherheitsnetz ist oder falsche Sicherheit vermittelt.

12 Min. Lesezeit assertSame · assertEquals · Custom Assertions · Constraints PHPUnit 10/11 · PHP 8.x

1. Warum Assertions mehr als Boilerplate sind

Eine Assertion ist die einzige Stelle im Test, an der explizit ausgedrückt wird, was das System tun soll. Alles davor – das Anlegen von Fixtures, das Aufrufen von Methoden, das Vorbereiten von Daten – ist Vorbereitung. Die Assertion selbst ist die Behauptung: "Dieses Ergebnis entspricht meiner Erwartung." Wählt man die falsche Assertion, ist diese Behauptung ungenau, und ein ungenauer Test kann grün sein, obwohl das System fehlerhaft ist.

Das klassische Beispiel: assertEquals(0, false) besteht in PHPUnit, weil assertEquals intern == verwendet und PHP 0 == false als wahr bewertet. Gemeint war aber: "Der Rückgabewert ist die Ganzzahl null, nicht false." Diesen Unterschied macht nur assertSame(0, false) sichtbar – und der Test schlägt korrekt fehl. Die Wahl zwischen diesen beiden Methoden ist keine Stilfrage, sondern eine Präzisionsfrage. In einem gut strukturierten Test-Suite sagt jede Assertion genau das aus, was sie meint – nicht mehr und nicht weniger.

Ein weiterer häufig übersehener Aspekt: Assertions sind Dokumentation. Wer einen Test liest, versteht durch die Assertions sofort, welche Eigenschaften des Systems sichergestellt werden. Eine Assertion wie assertSame('active', $user->getStatus()) ist klarer als assertTrue($user->getStatus() === 'active') – beide prüfen dasselbe, aber die erste Variante erzeugt bei einem Fehler eine aussagekräftigere Fehlermeldung und signalisiert dem Leser die Intention direkt.

2. assertSame vs. assertEquals: Der entscheidende Unterschied

assertSame verwendet intern den Identitätsoperator === und prüft Wert und Typ gleichzeitig. assertEquals verwendet == und wendet PHPs Typkoercion an. Diese scheinbar kleine Differenz hat erhebliche Konsequenzen: assertEquals(1, true) besteht, assertEquals(0, '') besteht, assertEquals(0, null) besteht – alles Kombinationen, die bei fehlerhaftem Code falsche Sicherheit erzeugen. Die Faustregel lautet: Immer assertSame verwenden, wenn der Typ des Rückgabewertes bekannt ist. assertEquals ist dann sinnvoll, wenn bewusst typlose Gleichheit geprüft werden soll – zum Beispiel beim Vergleich von DTO-Objekten, die __equals implementieren.

Bei Objekten prüft assertSame Objektidentität (dieselbe Instanz), während assertEquals Gleichheit prüft (gleiche Eigenschaften). Für Value Objects, die per Wert verglichen werden sollen, ist assertEquals also richtig. Für Service-Objekte, bei denen geprüft werden soll, ob eine Factory immer dieselbe Instanz zurückgibt (Singleton, Shared Service), ist assertSame notwendig. Den Unterschied kennen und bewusst einsetzen ist der erste Schritt zu präzisen Assertions.


<?php

declare(strict_types=1);

namespace Tests\Unit;

use PHPUnit\Framework\TestCase;
use App\Domain\Money;
use App\Domain\User;

/**
 * Demonstrates correct assertion selection for type-safe PHP tests.
 */
final class AssertionPrecisionTest extends TestCase
{
    /** @test */
    public function same_checks_type_and_value(): void
    {
        // WRONG: passes even though types differ
        $this->assertEquals(0, false);  // true — type coercion
        $this->assertEquals('', null); // true — type coercion

        // RIGHT: strict identity check
        $this->assertSame(0, 0);       // true
        // $this->assertSame(0, false); // FAILS — correct behaviour
    }

    /** @test */
    public function object_identity_vs_equality(): void
    {
        $a = new Money(100, 'EUR');
        $b = new Money(100, 'EUR');

        // Value equality (same properties): use assertEquals
        $this->assertEquals($a, $b);

        // Instance identity (same object in memory): use assertSame
        $registry = new ServiceRegistry();
        $this->assertSame($registry->get('mailer'), $registry->get('mailer'));
    }

    /** @test */
    public function null_checks_need_explicit_assertion(): void
    {
        $result = findUserById(999);

        // WRONG: assertEquals(null, $result) passes for false, 0, '' too
        // RIGHT: dedicated assertion communicates intent clearly
        $this->assertNull($result);
    }

    /** @test */
    public function boolean_checks_must_be_strict(): void
    {
        $isActive = $this->getUserStatus();

        // WRONG: assertTrue passes for any truthy value (1, 'yes', [1])
        // RIGHT: assertSame communicates exactly what is expected
        $this->assertSame(true, $isActive);
        $this->assertIsBool($isActive); // type guard before value check
    }
}

3. Die richtigen Assertions für Typen und Strukturen

PHPUnit bietet spezialisierte Assertions für jeden PHP-Typen, und diese zu nutzen ist besser als generische Varianten. assertIsString, assertIsInt, assertIsArray, assertIsFloat prüfen den Typ explizit und erzeugen klare Fehlermeldungen. assertCount ist besser als assertEquals(3, count($array)), weil PHPUnit den Zähler intern auswertet und die Fehlermeldung die tatsächliche Elementanzahl anzeigt. assertEmpty und assertNotEmpty sind klarer als assertEquals([], $array), weil sie auf alle leeren Strukturen ansprechen.

Für Arrays gibt es assertContains für Werte und assertArrayHasKey für Schlüssel. Bei assoziativen Arrays mit bekannter Struktur ist assertSame auf den gesamten Array oft präziser als mehrere einzelne assertArrayHasKey-Aufrufe. Für Strings gibt es assertStringContainsString, assertStringStartsWith, assertStringEndsWith und assertMatchesRegularExpression – jede mit klarer Fehlermeldung, die den tatsächlichen String und die Erwartung gegenüberstellt. Diese spezialisierten Assertions sind keine Luxury, sondern Werkzeuge, die das Debugging von fehlgeschlagenen Tests erheblich beschleunigen.


<?php

declare(strict_types=1);

namespace Tests\Unit;

use PHPUnit\Framework\TestCase;

/**
 * Shows specialized assertions for types and structures.
 */
final class SpecializedAssertionsTest extends TestCase
{
    /** @test */
    public function use_type_specific_assertions(): void
    {
        $order = $this->createOrder();

        // Type guards communicate intent and give clear error messages
        $this->assertIsString($order->getOrderNumber());
        $this->assertIsInt($order->getItemCount());
        $this->assertIsFloat($order->getTotalAmount());
        $this->assertIsArray($order->getItems());

        // Structural assertions
        $this->assertCount(3, $order->getItems());
        $this->assertNotEmpty($order->getOrderNumber());
        $this->assertStringStartsWith('ORD-', $order->getOrderNumber());
        $this->assertMatchesRegularExpression('/^ORD-\d{8}$/', $order->getOrderNumber());
    }

    /** @test */
    public function array_assertions_pinpoint_failures(): void
    {
        $config = $this->loadConfig();

        // Key existence before value access
        $this->assertArrayHasKey('database', $config);
        $this->assertArrayHasKey('host', $config['database']);

        // Value membership
        $this->assertContains('mysql', $config['supported_drivers']);

        // Full structure assertion for small arrays
        $this->assertSame([
            'host' => 'localhost',
            'port' => 3306,
        ], $config['database']);
    }

    /** @test */
    public function exception_assertions_need_specificity(): void
    {
        // WRONG: only checks exception class, ignores message
        $this->expectException(\InvalidArgumentException::class);

        // RIGHT: also assert the message to catch wrong exceptions of same type
        $this->expectException(\InvalidArgumentException::class);
        $this->expectExceptionMessage('Price must be positive');
        $this->expectExceptionCode(422);

        new Money(-1, 'EUR');
    }
}

4. Custom Assertions: Tests sprechen die Domänensprache

Custom Assertions sind eine der wirksamsten Techniken, um Test-Code lesbar und wartbar zu halten. Statt in jedem Test denselben Cluster von drei oder vier Assertions zu wiederholen, kapselt man diese in eine benannte Methode, die die Domänensprache spricht. Das Ergebnis: Ein Test liest sich wie eine Beschreibung des Verhaltens, nicht wie eine Abfolge von technischen Prüfschritten.

Custom Assertions werden am besten in einer gemeinsamen Basisklasse oder in einem Trait definiert, den alle betroffenen TestCase-Klassen nutzen. Die Methode beginnt konventionell mit assert, akzeptiert das zu prüfende Objekt und delegiert intern an PHPUnits Standard-Assertions. Wichtig: Custom Assertions sollten keine eigene Testlogik implementieren – sie kapseln nur Assertions. Logik gehört ins Produktionssystem, nicht in Assertions.


<?php

declare(strict_types=1);

namespace Tests\Support;

use PHPUnit\Framework\TestCase;
use App\Domain\Order;
use App\Domain\User;

/**
 * Provides domain-specific custom assertions for reuse across test cases.
 */
abstract class DomainTestCase extends TestCase
{
    /**
     * Asserts that an order is in a valid placed state.
     */
    protected function assertOrderIsPlaced(Order $order): void
    {
        $this->assertSame('placed', $order->getStatus());
        $this->assertNotNull($order->getPlacedAt());
        $this->assertGreaterThan(0, $order->getItemCount());
        $this->assertGreaterThan(0.0, $order->getTotalAmount());
        $this->assertNotEmpty($order->getOrderNumber());
    }

    /**
     * Asserts that a user has completed onboarding.
     */
    protected function assertUserOnboardingComplete(User $user): void
    {
        $this->assertTrue($user->isEmailVerified());
        $this->assertNotNull($user->getProfileCompletedAt());
        $this->assertSame('active', $user->getStatus());
        $this->assertNotEmpty($user->getDisplayName());
    }

    /**
     * Asserts that a collection contains exactly the given IDs.
     *
     * @param list<int> $expectedIds
     * @param list<object> $items
     */
    protected function assertCollectionContainsIds(array $expectedIds, array $items): void
    {
        $actualIds = array_map(static fn($item) => $item->getId(), $items);
        sort($expectedIds);
        sort($actualIds);
        $this->assertSame($expectedIds, $actualIds, 'Collection does not contain expected IDs.');
    }
}

// Usage in a test:
final class OrderPlacementTest extends DomainTestCase
{
    /** @test */
    public function placing_an_order_transitions_status_correctly(): void
    {
        $order = Order::draft();
        $order->place(items: $this->createItems(3), customer: $this->createCustomer());

        // Domain language — reads like a spec, not like assertions
        $this->assertOrderIsPlaced($order);
    }
}

5. Constraint-Objekte: Assertions kompositional einsetzen

PHPUnits Assertion-Methoden sind intern auf Constraint-Objekte aufgebaut. Die Klasse PHPUnit\Framework\Constraint\Constraint definiert die Schnittstelle, und PHPUnit bietet eine umfangreiche Bibliothek fertiger Constraints: IsEqual, IsIdentical, IsType, IsNull, Contains und viele mehr. Mit assertThat($value, $constraint) können diese direkt oder kombiniert eingesetzt werden.

Constraints lassen sich mit logicalAnd, logicalOr und logicalNot verknüpfen. So entsteht eine kompositionelle Assertion-Sprache: "Prüfe, dass der Wert ein String ist UND mit 'ORD-' beginnt UND mindestens 12 Zeichen hat." Eigene Constraints implementiert man, indem man von Constraint erbt und die Methoden matches() und toString() implementiert. Die toString()-Methode liefert die Fehlermeldung, die in der Failure-Ausgabe erscheint.


<?php

declare(strict_types=1);

namespace Tests\Constraint;

use PHPUnit\Framework\Constraint\Constraint;

/**
 * Custom constraint: asserts that a value is a valid order number.
 */
final class IsValidOrderNumber extends Constraint
{
    /**
     * Returns whether the constraint is matched by the given value.
     */
    protected function matches(mixed $other): bool
    {
        if (!is_string($other)) {
            return false;
        }

        return (bool) preg_match('/^ORD-\d{8}-[A-Z]{3}$/', $other);
    }

    /**
     * Returns a string representation of the constraint.
     */
    public function toString(): string
    {
        return 'matches order number format ORD-YYYYMMDD-XXX';
    }
}

// Composing constraints in a test
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Constraint\IsType;

final class OrderNumberTest extends TestCase
{
    /** @test */
    public function order_number_matches_format(): void
    {
        $orderNumber = $this->service->generateOrderNumber();

        // Composing built-in and custom constraints
        $this->assertThat(
            $orderNumber,
            $this->logicalAnd(
                new IsType('string'),
                new IsValidOrderNumber()
            )
        );
    }

    /** @test */
    public function price_is_within_valid_range(): void
    {
        $price = $this->pricing->calculate($product);

        $this->assertThat(
            $price,
            $this->logicalAnd(
                $this->greaterThan(0.0),
                $this->lessThanOrEqual(9999.99)
            )
        );
    }
}

6. Aussagekräftige Fehlermeldungen schreiben

Jede PHPUnit-Assertion akzeptiert als letzten Parameter eine optionale Nachricht, die im Fehlerfall angezeigt wird. Diese Möglichkeit wird viel zu selten genutzt. Dabei ist eine aussagekräftige Fehlermeldung oft der entscheidende Unterschied zwischen "Test fehlgeschlagen – Ursache sofort klar" und "Test fehlgeschlagen – zwanzig Minuten debuggen". Die Nachricht sollte beschreiben, warum dieser spezifische Wert in diesem Kontext erwartet wird, nicht was PHPUnit ohnehin in der Fehlerausgabe anzeigt.

Ein schlechtes Beispiel: $this->assertSame(3, $count, 'count ist falsch'). Das sagt nichts Neues. Ein gutes Beispiel: $this->assertSame(3, $count, 'Cart should contain exactly 3 items after adding product twice to empty cart'). Diese Nachricht erklärt den Kontext des Tests und macht klar, unter welchen Bedingungen dieser Fehler aufgetreten ist. In Test-Suites mit hunderten Tests ist diese Information Gold wert, wenn CI nach einer Änderung Fehlschläge meldet.

7. Typische Assertions-Fehler und wie man sie erkennt

Der häufigste Fehler ist der Einsatz von assertTrue für Vergleiche: assertTrue($a === $b) statt assertSame($a, $b). Das Problem: PHPUnit zeigt bei assertTrue-Fehlern nur "Failed asserting that false is true" – ohne zu zeigen, was $a und $b tatsächlich enthielten. Bei assertSame hingegen zeigt PHPUnit den tatsächlichen und den erwarteten Wert nebeneinander. Die Fehlermeldung ist dadurch sofort umsetzbar, ohne den Debugger zu starten.

Ein zweiter verbreiteter Fehler: Assertions auf Objekte ohne Blick auf Gleichheitssemantik. Wenn ein Objekt keine Gleichheitsmethode implementiert, vergleicht assertEquals alle Eigenschaften rekursiv – was bei großen Objektgraphen langsam ist und bei zirkulären Referenzen fehlschlägt. Explizit die relevanten Eigenschaften prüfen ist dann robuster. Ein dritter Fehler: assertContains auf assoziative Arrays verhält sich anders als auf Listen. Bei assoziativen Arrays prüft assertContains Werte, nicht Schlüssel – für Schlüssel ist assertArrayHasKey die richtige Wahl.

Szenario Falsch / Schwach Richtig / Präzise Warum
Typprüfung assertEquals(0, false) assertSame(0, 0) Typkoercion verschleiert Fehler
Boolean-Check assertTrue($isActive) assertSame(true, $isActive) assertTrue akzeptiert Truthy-Werte
Null-Check assertEquals(null, $r) assertNull($r) Klare Fehlermeldung, kein Koercion-Risiko
Anzahl Elemente assertEquals(3, count($a)) assertCount(3, $a) Fehlermeldung zeigt Elemente
String-Inhalt assertTrue(str_contains($s, 'X')) assertStringContainsString('X', $s) Zeigt Needle und Haystack im Fehler

8. Assertions im direkten Vergleich

Die Wahl der richtigen Assertion beeinflusst nicht nur Korrektheit, sondern auch die Qualität der Fehlermeldungen und die Lesbarkeit des Tests. Die folgende Übersicht zeigt die wichtigsten Paarungen und wann welche Variante einzusetzen ist.

9. Zusammenfassung

Bessere PHPUnit-Assertions schreiben bedeutet: assertSame statt assertEquals, wenn der Typ bekannt ist. Spezialisierte Assertions wie assertCount, assertNull, assertIsString statt generischer Varianten. Custom Assertions in Basisklassen für domänenspezifische Prüfungen, die den Test lesbar halten. Constraint-Objekte für kompositionale Assertions. Und immer: eine Fehlermeldung, die erklärt, warum dieser Wert in diesem Kontext erwartet wird.

Der wichtigste Grundsatz bleibt Klarheit vor Cleverness. Eine Assertion, die auf den ersten Blick verständlich ist und beim Fehlschlagen sofort zeigt, was schiefgelaufen ist, ist einer cleveren Einzeiler-Assertion jederzeit vorzuziehen. Tests sind Dokumentation – Assertions sind die präziseste Form davon.

PHPUnit-Assertions — Das Wichtigste auf einen Blick

Typ-Präzision

assertSame verwendet === – immer nutzen, wenn Typ des Rückgabewertes bekannt ist. assertEquals nur für bewusste Typfreiheit.

Spezialisierte Assertions

assertNull, assertCount, assertIsString, assertStringContainsString – erzeugen klare Fehlermeldungen statt generischem "false is not true".

Custom Assertions

Domänenspezifische Prüfungen in Basisklassen auslagern. Test liest sich wie eine Spec, nicht wie technische Checks.

Fehlermeldungen

Letzten Parameter nutzen: Kontext beschreiben, nicht wiederholen was PHPUnit ohnehin anzeigt. Fehlschlag muss sofort verständlich sein.

Mironsoft

PHP-Entwicklung, Testing-Infrastruktur und Code-Qualitätssicherung

Test-Suites, die wirklich schützen?

Wir analysieren bestehende PHPUnit-Test-Suites, identifizieren schwache Assertions und schwache Abdeckung – und refaktorieren zu präzisen, lesbaren Tests, die echte Sicherheit geben.

Assertion-Audit

Analyse aller Assertions auf Präzision, Typ-Sicherheit und Fehlermeldungsqualität

Custom Assertions

Domänenspezifische Basisklassen und Constraints für lesbare, wartbare Tests

Test-Refactoring

Schwache Tests durch präzise, aussagekräftige Alternativen ersetzen

10. FAQ: Bessere PHPUnit-Assertions schreiben

1Wann assertSame statt assertEquals?
Immer wenn Typ bekannt ist. assertSame nutzt ===, assertEquals nutzt == mit Typkoercion – führt zu falsch-positiven Tests bei 0/false/null/''.
2assertSame vs. assertEquals bei Objekten?
assertSame prüft Instanzidentität (gleiche Referenz). assertEquals prüft Eigenschaftsgleichheit. Value Objects: assertEquals. Singletons: assertSame.
3Warum assertTrue($a === $b) schlecht ist?
Fehlermeldung: "false is not true" – ohne Werte. assertSame zeigt erwarteten und tatsächlichen Wert. Direkt umsetzbar ohne Debugger.
4Wie Custom Assertion implementieren?
Methode mit assert-Präfix in Basisklasse oder Trait. Intern an Standard-Assertions delegieren. Keine eigene Testlogik – nur Assertions kapseln.
5Was sind PHPUnit Constraints?
Interne Schicht hinter allen Assertions. Mit assertThat() und logicalAnd/Or/Not kombinierbar. Eigene Constraints durch Erben von Constraint implementieren.
6Gute Fehlermeldungen schreiben?
Letzten Parameter nutzen. Kontext beschreiben, nicht wiederholen was PHPUnit ohnehin zeigt. Ziel: beim Fehlschlag sofort Ursache verstehen.
7assertContains auf assoziative Arrays?
assertContains prüft Werte, nicht Schlüssel. Für Schlüssel: assertArrayHasKey. Für vollständige Struktur: assertSame auf den gesamten Array.
8expectException richtig einsetzen?
Immer mit expectExceptionMessage und ggf. expectExceptionCode kombinieren – sonst wird nur die Klasse geprüft, nicht ob die richtige Exception aus dem richtigen Grund geworfen wird.
9assertEmpty für welche Werte?
assertEmpty ist truthy-basiert: 0, '', null, [], false gelten alle als leer. Für spezifisch leeres Array: assertSame([], $value) ist präziser.
10Wie viele Assertions pro Test?
So viele wie nötig für vollständige Verhaltensbeschreibung. Regel "eine Assertion" ist zu restriktiv. Wichtig: alle beziehen sich auf dieselbe Verhaltenseinheit. Custom Assertions bündeln Checks lesbar.