@test
assert
PHPUnit · Mocking · Anti-Patterns · Test-Design
Mocking-Anti-Patterns:
Wenn Tests nur Implementierungsdetails abbilden

Ein Test, der bei jeder Implementierungsänderung rot wird, ohne dass sich das beobachtbare Verhalten geändert hat, ist kein Test – er ist ein Hindernis. Overspecified Mocks, das Testen eigener Klassen über Mocks, Spy-Missbrauch und Expectation-Overload sind die häufigsten Ursachen für brittle PHPUnit-Suites, die mehr Wartungsaufwand erzeugen als Sicherheit liefern.

16 Min. Lesezeit Overspecified Mocks · Mock-what-you-own · Spy-Abuse · Expectation-Overload PHP 8.4 · PHPUnit 11 · Test-Design

1. Das Grundproblem: Tests, die die Implementierung beschreiben

Ein guter Test prüft das beobachtbare Verhalten eines Softwarebausteins: Was wird zurückgegeben, was wird geworfen, welchen Zustand hat das System danach? Ein schlechter Test prüft, wie die Implementierung intern funktioniert: In welcher Reihenfolge werden interne Methoden aufgerufen, wie oft wird eine bestimmte Abhängigkeit konsultiert, mit welchen internen Zwischenwerten wird gearbeitet? Der Unterschied zwischen diesen beiden Test-Arten ist der Unterschied zwischen einem Test, der beim Refactoring hilft, und einem Test, der das Refactoring verhindert.

Das Problem entsteht fast immer durch Mocks. Mocks sind das mächtigste Werkzeug in der PHPUnit-Toolbox – und das am häufigsten missbrauchte. Wenn ein Test fünf expects($this->once())-Calls hat, ist er kein Test des Verhaltens, sondern ein Skript der Implementierung. Wenn eine Klasse gemockt wird, die keinen IO-Zugriff macht und keine Netzwerkkommunikation hat, ist das Mock ein Hinweis, dass die Klassenstruktur überarbeitet werden sollte. Die folgenden Anti-Patterns treten in PHP-Projekten jeder Größe auf – von kleinen Magento-Modulen bis zu großen E-Commerce-Plattformen.

2. Overspecified Mocks: zu viele Expectations

Das häufigste Mocking-Anti-Pattern ist die Überspecifizierung: Ein Test, der für jede interne Interaktion eine eigene Expectation setzt, auch wenn diese Interaktion nicht Teil des öffentlichen Contracts ist. Ein klassisches Beispiel ist ein Test für eine Methode, die intern zwei verschiedene Repository-Methoden aufruft. Der Test setzt nicht nur die willReturn-Werte, sondern auch expects($this->once()) für beide – obwohl nur das Ergebnis der Methode getestet werden soll.

Das Ergebnis: Wenn die Implementierung refactored wird und eine der Repository-Methoden durch eine andere ersetzt wird – ohne dass sich das Verhalten der getesteten Methode ändert – wird der Test rot. Der Test hat kein echtes Verhalten geschützt, sondern die interne Implementierung fixiert. Das korrekte Muster: expects($this->any()) oder ganz auf Expectation-Counts verzichten und nur willReturn setzen. Counts nur dann, wenn das genaue Aufrufen eine Geschäftsregel darstellt – etwa dass ein teurer API-Aufruf durch Caching genau einmal stattfindet.


<?php
// ANTI-PATTERN: Overspecified Mock — tests internal call sequence, not behavior
class OrderServiceAntiPatternTest extends TestCase
{
    public function testCreatesOrderAntiPattern(): void
    {
        $inventoryMock = $this->createMock(InventoryRepositoryInterface::class);
        // BAD: These expectations tie the test to internal implementation details
        $inventoryMock->expects($this->once())    // why exactly once? Is that a business rule?
            ->method('checkAvailability')
            ->willReturn(true);
        $inventoryMock->expects($this->once())    // internal impl detail, not behavior
            ->method('reserveStock');

        $orderMock = $this->createMock(OrderRepositoryInterface::class);
        $orderMock->expects($this->once())        // we care that the order is saved, but not how many times
            ->method('save')
            ->willReturn($this->createMock(OrderInterface::class));

        $service = new OrderService($inventoryMock, $orderMock);
        $service->createOrder(['sku' => 'PROD-001', 'qty' => 2]);
    }
}

// GOOD PATTERN: Test behavior, not internal call counts
class OrderServiceTest extends TestCase
{
    public function testCreatesOrderAndReturnsOrderId(): void
    {
        $inventoryMock = $this->createMock(InventoryRepositoryInterface::class);
        // Only configure what we need the mock to return — no call count assertions
        $inventoryMock->method('checkAvailability')->willReturn(true);
        $inventoryMock->method('reserveStock'); // returns void, no assertion needed

        $savedOrder = $this->createMock(OrderInterface::class);
        $savedOrder->method('getId')->willReturn(1001);

        $orderMock = $this->createMock(OrderRepositoryInterface::class);
        $orderMock->method('save')->willReturn($savedOrder);

        $service  = new OrderService($inventoryMock, $orderMock);
        $result   = $service->createOrder(['sku' => 'PROD-001', 'qty' => 2]);

        // Test the OUTCOME: what does the caller of createOrder care about?
        $this->assertSame(1001, $result->getId());
    }

    public function testThrowsWhenItemNotAvailable(): void
    {
        $inventoryMock = $this->createMock(InventoryRepositoryInterface::class);
        $inventoryMock->method('checkAvailability')->willReturn(false);

        $orderMock = $this->createMock(OrderRepositoryInterface::class);
        $orderMock->expects($this->never())->method('save'); // THIS is a business rule expectation

        $service = new OrderService($inventoryMock, $orderMock);

        $this->expectException(OutOfStockException::class);
        $service->createOrder(['sku' => 'PROD-001', 'qty' => 2]);
    }
}

3. Mock-What-You-Own: nie fremde Klassen direkt mocken

Eine Grundregel des Test-Designs lautet: Mock only what you own. Das bedeutet: Fremde Klassen – Bibliotheken, Framework-Klassen, Vendor-Packages – sollten nicht direkt gemockt werden. Stattdessen schreibt man einen eigenen Wrapper oder Adapter, der die fremde Klasse einkapselt, und mockt diesen Adapter. Das klingt nach zusätzlichem Aufwand, hat aber entscheidende Vorteile: Wenn die fremde Bibliothek ihre interne API ändert, bricht nur der Adapter-Test (oder der Adapter selbst), nicht alle Tests, die direkt die fremde Klasse gemockt haben.

In Magento-Projekten sieht man dieses Anti-Pattern häufig beim Mocken von Guzzle-Klassen (createMock(Client::class)), Doctrine-Klassen oder Symfony-Komponenten. Das Problem: Ein Mock von Client::class hat keine Kenntnis des echten Guzzle-Verhaltens – der Mock erlaubt Aufrufe, die im echten Client nie existiert haben. Wenn Guzzle eine Methode umbenennt und der eigene Code diese Methode aufruft, schlägt der echte Code fehl, aber der Test mit dem Mock bleibt grün. Das ist die schlimmste Eigenschaft eines Tests: falsches Grün.


<?php
// ANTI-PATTERN: Directly mocking a third-party class
class ShipmentServiceAntiPatternTest extends TestCase
{
    public function testCreatesShipmentLabelAntiPattern(): void
    {
        // BAD: Mocking a Guzzle class directly couples tests to Guzzle internals
        $guzzleMock = $this->createMock(\GuzzleHttp\Client::class);
        $guzzleMock->method('post')->willReturn(
            new \GuzzleHttp\Psr7\Response(200, [], '{"label_url": "https://..."}')
        );

        // If Guzzle renames 'post' to 'request', this test stays green but production breaks
        $service = new ShipmentService($guzzleMock);
        $label   = $service->createLabel('DE', 'AT', 1.5);
        $this->assertNotEmpty($label->getUrl());
    }
}

// GOOD PATTERN: Own adapter wraps the third-party class — only mock your own interface
interface ShippingHttpClientInterface
{
    /** @param array<mixed> $payload */
    public function post(string $endpoint, array $payload): array;
}

class ShipmentServiceTest extends TestCase
{
    public function testCreatesShipmentLabel(): void
    {
        // GOOD: Mocking our own interface — we control its contract
        $httpMock = $this->createMock(ShippingHttpClientInterface::class);
        $httpMock->method('post')->willReturn(['label_url' => 'https://carrier.example.com/label/123']);

        $service = new ShipmentService($httpMock);
        $label   = $service->createLabel('DE', 'AT', 1.5);

        $this->assertStringContainsString('carrier.example.com', $label->getUrl());
    }
}

4. Spy-Missbrauch: expects() statt Ergebnis-Assertions

Ein weiteres verbreitetes Anti-Pattern ist der Missbrauch von Mocks als Spies, um Seiteneffekte statt Rückgabewerte zu testen. Das Problem zeigt sich, wenn ein Test expects($this->once())->method('log') auf einem Logger-Mock hat, statt zu prüfen, ob das System nach dem Aufruf im richtigen Zustand ist. Logger-Aufrufe, Cache-Invalidierungen und Event-Dispatches sind interne Details – die Frage, ob ein Fehlerlog erzeugt wird, ist selten wichtiger als die Frage, was der Aufrufer als Ergebnis erhält.

Wenn das Testen von Seiteneffekten wirklich eine Geschäftsanforderung abbildet – etwa dass ein Audit-Log bei bestimmten Aktionen geschrieben werden muss – ist ein Spy legitim. Aber die Erwartung sollte das Ergebnis des Logs beschreiben, nicht die Tatsache des Aufrufs: expects($this->once())->method('log')->with($this->stringContains('order.created')) ist besser als nur expects($this->once())->method('log'). Noch besser: einen eigenen AuditLog-Fake implementieren, der Einträge in einem Array speichert, und direkt auf den Inhalt des Arrays assertieren.


<?php
// ANTI-PATTERN: Testing side effects via spy instead of testing behavior
class PaymentServiceAntiPatternTest extends TestCase
{
    public function testLogsPaymentAttemptAntiPattern(): void
    {
        $loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class);
        // BAD: We care that log() is called, not what the service actually does
        $loggerMock->expects($this->once())->method('info');
        $loggerMock->expects($this->never())->method('error');

        $gateway = $this->createMock(PaymentGatewayInterface::class);
        $gateway->method('charge')->willReturn(['status' => 'success', 'transaction_id' => 'TXN-99']);

        $service = new PaymentService($gateway, $loggerMock);
        $service->processPayment(99.00, 'card_token_123');
        // Missing: assertion on WHAT the service returned or what state changed
    }
}

// GOOD PATTERN: Test the outcome; use a simple fake for side-effect verification
final class InMemoryAuditLog implements AuditLogInterface
{
    private array $entries = [];

    public function record(string $event, array $context = []): void
    {
        $this->entries[] = ['event' => $event, 'context' => $context];
    }

    public function hasEntry(string $event): bool
    {
        return in_array($event, array_column($this->entries, 'event'), true);
    }
}

class PaymentServiceTest extends TestCase
{
    public function testProcessesPaymentSuccessfully(): void
    {
        $gateway = $this->createMock(PaymentGatewayInterface::class);
        $gateway->method('charge')->willReturn(['status' => 'success', 'transaction_id' => 'TXN-99']);

        $auditLog = new InMemoryAuditLog();
        $service  = new PaymentService($gateway, $auditLog);

        $result = $service->processPayment(99.00, 'card_token_123');

        // Test the RETURN VALUE — the primary behavior
        $this->assertTrue($result->isSuccessful());
        $this->assertSame('TXN-99', $result->getTransactionId());

        // Only check side effect if it's a business requirement
        $this->assertTrue($auditLog->hasEntry('payment.processed'));
    }
}

5. Konkrete Klassen mocken: das Seam-Problem

PHPUnit ermöglicht es, konkrete Klassen zu mocken – createMock(ConcreteClass::class) erstellt eine Unterklasse, die alle Methoden überschreibt und deren Rückgabewerte konfiguriert werden können. Das klingt praktisch, ist aber problematisch: Das Mocken einer konkreten Klasse ist ein Signal, dass keine saubere Abstraktion existiert. Wenn der Code direkt auf new ConcreteClass() oder auf eine konkrete Klasse per Constructor angewiesen ist, ohne Interface dazwischen, ist das Mock ein Pflaster auf ein Architekturproblem.

Das tiefere Problem: Wenn man eine konkrete Klasse mockt, mockt man implizit ein Interface, das nur in der Phantasie des Tests existiert. Sobald die konkrete Klasse neue Methoden bekommt oder bestehende umbenennt, sieht der Test das nicht – der Mock wurde auf Basis des alten Standes erstellt. Das korrekte Vorgehen: Interface extrahieren, konkrete Klasse implementiert das Interface, Code akzeptiert das Interface per Constructor. Tests mocken das Interface. Das macht die Abhängigkeit explizit und den Test robust gegenüber Implementierungsänderungen der konkreten Klasse.

6. Wann Mocks wirklich sinnvoll sind

Mocks sind das richtige Werkzeug in genau drei Situationen: Erstens, wenn eine Abhängigkeit IO-Operationen durchführt (Netzwerk, Dateisystem, Datenbank) und diese IO-Operationen im Test nicht stattfinden sollen. Zweitens, wenn das Verhalten der Abhängigkeit unter bestimmten Fehlerbedingungen getestet werden soll, die in der echten Implementierung schwer zu provozieren sind. Drittens, wenn eine Interaktion mit einer Abhängigkeit selbst eine Geschäftsregel darstellt – etwa dass ein Audit-Log zwingend geschrieben wird oder dass ein teurer Dienst durch Caching nicht mehrfach aufgerufen wird.

In allen anderen Fällen sind Fakes (einfache, eigene Implementierungen des Interfaces) oder echte Objekte die bessere Wahl. Ein InMemoryRepository, das Daten in einem Array speichert, ist wartbarer und ausdrucksstärker als ein Mock mit zwanzig willReturn-Konfigurationen. Er verhält sich wie ein echtes Repository, hat kein falsches Grün und lässt sich für viele Tests wiederverwenden. Das ist die oft übersehene dritte Option zwischen "echte Datenbank" und "Mock": der In-Memory-Fake als vollständige, aber leichtgewichtige Implementierung.

7. Anti-Pattern vs. empfohlenes Muster im Vergleich

Die häufigsten Mocking-Anti-Patterns und ihre empfohlenen Alternativen zeigen dasselbe Grundmuster: Das Anti-Pattern testet, wie der Code funktioniert. Das empfohlene Muster testet, was der Code macht.

Anti-Pattern Problem Empfohlenes Muster Vorteil
expects(once) überall Fixiert Implementierungsdetails method() ohne Count Refactoring ohne Testbruch
Fremde Klassen mocken Falsch Grün bei API-Änderungen Eigener Adapter + Mock des Adapters Tests bleiben bei Lib-Update stabil
Spy statt Ergebnis Testet wie, nicht was Return-Wert assertieren Test prüft tatsächliches Verhalten
Konkrete Klasse mocken Kein echtes Interface, blindes Mock Interface extrahieren, Interface mocken Explizite Abhängigkeit, robuster Test
20 willReturn-Konfigurationen Mock ist komplexer als echte Impl. In-Memory-Fake implementieren Einfacher, wiederverwendbar, kein falsch Grün

Die häufigste Wurzel aller Mocking-Anti-Patterns ist das Fehlen einer klaren Trennung zwischen Verhalten und Implementierung. Wer beim Schreiben eines Tests fragt "Was soll diese Methode tun?" statt "Wie macht diese Methode das?", schreibt automatisch bessere Tests – mit weniger Mocks, weniger Overspecification und besserer Widerstandsfähigkeit gegen Refactoring.

8. Zusammenfassung

Mocking-Anti-Patterns entstehen immer dann, wenn Tests die Implementierung statt das Verhalten beschreiben. Overspecified Mocks mit expects(once) auf jeder Methode fixieren die interne Aufruf-Struktur und brechen bei jedem Refactoring. Das Mocken fremder Klassen erzeugt falsch-grüne Tests, weil Mock-Interfaces nicht mit echten Bibliotheks-Interfaces synchronisiert sind. Spy-Missbrauch testet Seiteneffekte statt Rückgabewerte und erzeugt Tests, die nur Fragen über das Innere der Methode beantworten. Konkrete Klassen mocken ist ein Symptom fehlender Interface-Abstraktionen.

Die Lösung ist konsequente Fokussierung auf beobachtbares Verhalten: Was gibt die Methode zurück, welche Exception wirft sie, welchen Zustand hat das System danach? Mocks konfigurieren ohne Call-Count-Expectations, wenn kein Geschäfts-Grund für die genaue Anzahl besteht. Eigene Adapter vor fremden Bibliotheken. In-Memory-Fakes statt überkomplexer Mocks mit zwanzig willReturn-Konfigurationen. Diese Grundsätze machen PHPUnit-Testsuites zu einem Werkzeug, das Refactoring ermöglicht – statt zu einem Werkzeug, das Refactoring verhindert.

Mocking-Anti-Patterns — Das Wichtigste auf einen Blick

Verhalten, nicht Implementierung

Tests prüfen Rückgabewerte, Exceptions und Zustandsänderungen – nicht die interne Aufruf-Reihenfolge oder Methoden-Counts.

Nur eigene Interfaces mocken

Fremde Klassen (Guzzle, Doctrine, Symfony) durch eigene Adapter kapseln. Nur den eigenen Adapter mocken – kein falsches Grün bei Lib-Updates.

In-Memory-Fakes statt Mocks

Einfache Interface-Implementierungen mit Array-Storage sind wartbarer als komplexe Mocks mit vielen willReturn-Konfigurationen.

expects(once) nur für Geschäftsregeln

Aufruf-Count-Expectations nur wenn die genaue Anzahl eine Geschäftsregel ist (Caching, Audit-Log). Sonst method() ohne Count.

9. FAQ: Mocking-Anti-Patterns in PHPUnit

1Was ist ein Overspecified Mock?
Zu viele expects()-Assertions auf Methoden, die keine Geschäftsregeln abbilden. Fixiert Implementierungsdetails und bricht bei jedem Refactoring – auch ohne Verhaltensänderung.
2Mock only what you own?
Fremde Bibliotheken nie direkt mocken. Eigenen Adapter schreiben, der die Bibliothek einkapselt. Den eigenen Adapter mocken – kein falsches Grün bei Lib-Updates.
3Wann ist expects(once) sinnvoll?
Nur wenn die genaue Anzahl eine Geschäftsregel ist: Caching (teurer API-Call genau einmal) oder Audit-Log (muss zwingend geschrieben werden). Nicht als generelles Absicherungs-Muster.
4In-Memory-Fake vs. Mock?
Fake besser bei vielen willReturn-Konfigurationen oder Wiederverwendung in vielen Tests. Einfach, kein falsch Grün, kein Overspecification-Risiko.
5Konkrete Klassen mocken: warum problematisch?
Kein explizites Interface. Mock sieht Änderungen an der konkreten Klasse nicht – führt zu falsch-grünen Tests. Lösung: Interface extrahieren und mocken.
6Spy-Missbrauch erkennen?
Test endet ohne Ergebnis-Assertion – nur expects() auf Mocks. Prüft was aufgerufen wurde, nicht was zurückkam. Test prüft Implementierung, nicht Verhalten.
7Logger mit Mock oder Fake testen?
Fake bevorzugen: FakeLogger speichert Einträge in Array. Nach dem Test auf Array-Inhalt assertieren – aussagekräftiger als expects(once) auf method('info').
8Mock vs. Stub in PHPUnit?
Stub: konfiguriert Rückgabewerte (willReturn), keine Expectations. Mock: fügt expects() hinzu. createMock() erzeugt technisch immer ein Mock-Objekt, kann aber als Stub verwendet werden.
9Methoden ohne Rückgabewert testen?
Zustand nach dem Aufruf prüfen – über andere Methoden der Klasse oder über FakeCollaborator, der den Zustand aufzeichnet. Nicht expects() als Ersatz für Ergebnis-Assertions.
10Wie viele Mocks pro Test?
Faustregel: mehr als 3 Mocks → zu viele Abhängigkeiten. Mehr als 3 willReturn pro Mock → In-Memory-Fake erwägen. Mehr als 5 Mocks → Klasse refactoren.