@test
assert
TDD · PHPUnit · PHP · Red-Green-Refactor
TDD in PHP realistisch anwenden
statt nur darüber reden

Test-Driven Development wird oft entweder verteufelt oder als Allheilmittel angepriesen. Die Realität in PHP-Projekten ist differenzierter: TDD funktioniert hervorragend für bestimmte Problemklassen und erzeugt unnötigen Overhead für andere. Dieser Beitrag zeigt, wo der Unterschied liegt – mit Code, nicht mit Theorie.

14 Min. Lesezeit TDD · Red-Green-Refactor · PHPUnit · Magento PHP 8.x · PHPUnit 10/11

1. Das Versprechen von TDD – und die Realität

Test-Driven Development verspricht, dass das Schreiben von Tests vor dem Produktionscode zu besserem Design führt. Das ist keine leere Behauptung – der Mechanismus dahinter ist real: Wenn man gezwungen ist, einen Test zu schreiben, bevor eine Klasse existiert, muss man die Schnittstelle dieser Klasse aus der Nutzerperspektive entwerfen. Das führt tendenziell zu fokussierteren Klassen mit klarerer Verantwortung, weil man nicht mehr Features einbaut als der Test verlangt.

Die Realität in PHP-Projekten sieht oft anders aus. Entwickler probieren TDD aus, schreiben drei Tests für eine neue Funktion, stoßen dann auf Datenbankzugriffe, komplexe Magento-Abhängigkeiten oder externe APIs – und geben auf. Das Problem liegt nicht in TDD selbst, sondern darin, dass TDD ohne testbaren Code nicht funktioniert. TDD ist kein Werkzeug, das man auf beliebigen Code anwendet. Es ist ein Design-Feedback-System, das voraussetzt, dass die Architektur Testbarkeit ermöglicht. Deshalb muss man zuerst verstehen, wo TDD funktioniert und wo nicht.

Ein weiterer Frustrationspunkt: das Tempo. TDD fühlt sich anfangs langsamer an. Man schreibt mehr Code pro Feature, weil Tests und Produktionscode parallel entstehen. Dieser Overhead ist real – er amortisiert sich durch weniger Debugging, weniger Regression und klareres Design. Aber das merkt man erst nach Wochen oder Monaten, nicht nach dem ersten Sprint.

2. Der Red-Green-Refactor-Zyklus konkret erklärt

Der Red-Green-Refactor-Zyklus ist das Herzstück von TDD und besteht aus drei strikt getrennten Phasen. Red: Ein Test wird geschrieben, der das gewünschte Verhalten beschreibt – und fehlschlägt, weil der Produktionscode noch nicht existiert. Das ist der beabsichtigte Ausgangspunkt. Ein sofort grüner Test, ohne Produktionscode, testet meistens nichts Sinnvolles. Green: Es wird gerade so viel Produktionscode geschrieben, dass der Test besteht. Nicht mehr. Kein vorzeitiges Optimieren, keine zusätzlichen Features, kein "das brauchen wir später". Die einzige Regel: Der Test wird grün.

Refactor: Mit grünen Tests im Rücken wird der Code verbessert. Duplikation entfernen, Namen klären, Abstraktionen einführen. Nach jedem Refactoring-Schritt laufen die Tests. Wenn ein Test rot wird, wurde eine Verhaltensänderung eingeführt – entweder war das beabsichtigt, dann muss der Test angepasst werden, oder es ist ein Refactoring-Fehler. Dieser Zyklus läuft in kleinen Schritten, typischerweise alle zwei bis fünf Minuten.

3. Erster TDD-Zyklus: Preisberechnung von Null

Ein konkretes Beispiel verdeutlicht den Zyklus besser als jede abstrakte Beschreibung. Aufgabe: Ein Service, der den Bruttopreis aus Nettopreis und Mehrwertsteuersatz berechnet. TDD startet mit dem ersten Test, bevor die Klasse existiert.


<?php

declare(strict_types=1);

namespace Mironsoft\Pricing\Test\Unit\Service;

use Mironsoft\Pricing\Service\TaxCalculator;
use PHPUnit\Framework\TestCase;

final class TaxCalculatorTest extends TestCase
{
    private TaxCalculator $calculator;

    protected function setUp(): void
    {
        $this->calculator = new TaxCalculator();
    }

    /** @test */
    public function it_calculates_gross_price_from_net_and_tax_rate(): void
    {
        // Red: TaxCalculator does not exist yet — test fails with class not found
        $gross = $this->calculator->calculateGross(net: 100.00, taxRate: 19.0);

        $this->assertEqualsWithDelta(119.00, $gross, 0.001);
    }

    /** @test */
    public function it_handles_zero_tax_rate(): void
    {
        $gross = $this->calculator->calculateGross(net: 50.00, taxRate: 0.0);

        $this->assertEqualsWithDelta(50.00, $gross, 0.001);
    }

    /** @test */
    public function it_throws_for_negative_net_price(): void
    {
        $this->expectException(\InvalidArgumentException::class);
        $this->expectExceptionMessage('Net price must not be negative');

        $this->calculator->calculateGross(net: -10.00, taxRate: 19.0);
    }
}

Erst nach dem Schreiben aller Tests wird die Klasse erstellt – mit genau so viel Code, wie die Tests verlangen. Das Ergebnis ist eine Klasse mit klarer Schnittstelle, die exakt die dokumentierten Anforderungen erfüllt und nicht mehr.

4. TDD für Services mit Abhängigkeiten

Sobald Services externe Abhängigkeiten haben – Repositories, APIs, Datenbanken – wird TDD ohne Mocking unmöglich. Der Test würde bei jedem Lauf gegen echte Infrastruktur arbeiten und wäre damit langsam, fragil und umgebungsabhängig. PHPUnit bietet mit createMock() und createStub() zwei Mechanismen: Stubs liefern konfigurierte Rückgabewerte ohne Verhaltensverifikation, Mocks verifizieren zusätzlich, wie Abhängigkeiten aufgerufen werden.

Der TDD-Ansatz bei Services mit Abhängigkeiten: Im Test wird zuerst definiert, welche Abhängigkeiten der Service benötigt und wie er mit ihnen interagiert. Das zwingt zu einer klaren Trennung von Businesslogik und Infrastruktur. Ein Service, der direkt Datenbankabfragen enthält, lässt sich nicht sinnvoll mit TDD entwickeln – die notwendige Infrastruktur steht im Weg. TDD erzwingt hier das richtige Design: Repository-Pattern, Dependency Injection, klare Interfaces.


<?php

declare(strict_types=1);

namespace Mironsoft\Inventory\Test\Unit\Service;

use Mironsoft\Inventory\Api\StockRepositoryInterface;
use Mironsoft\Inventory\Service\LowStockNotifier;
use PHPUnit\Framework\TestCase;

final class LowStockNotifierTest extends TestCase
{
    /** @test */
    public function it_triggers_notification_when_stock_falls_below_threshold(): void
    {
        // Arrange: define dependency behavior before writing the service
        $repository = $this->createMock(StockRepositoryInterface::class);
        $repository->method('findBelowThreshold')
            ->with(5) // threshold
            ->willReturn(['SKU-001', 'SKU-042']);

        $mailer = $this->createMock(\Mironsoft\Notification\MailerInterface::class);
        $mailer->expects($this->once())
            ->method('sendLowStockAlert')
            ->with(['SKU-001', 'SKU-042']);

        // Act
        $notifier = new LowStockNotifier($repository, $mailer);
        $notifier->checkAndNotify(threshold: 5);
    }

    /** @test */
    public function it_does_not_send_notification_when_stock_is_sufficient(): void
    {
        $repository = $this->createStub(StockRepositoryInterface::class);
        $repository->method('findBelowThreshold')->willReturn([]);

        $mailer = $this->createMock(\Mironsoft\Notification\MailerInterface::class);
        $mailer->expects($this->never())->method('sendLowStockAlert');

        $notifier = new LowStockNotifier($repository, $mailer);
        $notifier->checkAndNotify(threshold: 5);
    }
}

5. Wo TDD wirklich hilft

TDD entfaltet seinen größten Nutzen bei Businesslogik mit klaren Eingabe-Ausgabe-Beziehungen: Preisberechnungen, Rabattregeln, Validierungslogik, Zustandsautomaten, Parsers. In all diesen Fällen ist die erwartete Ausgabe für gegebene Eingaben vorab klar definierbar. TDD zwingt dazu, jeden Grenzfall explizit zu benennen: Was passiert bei negativen Werten? Bei Null? Bei Übergabe eines leeren Arrays? Diese Fragen stellt man bei TDD zwingend, weil der Test sie abbilden muss. Bei Test-after werden sie oft übersehen.

Ein zweiter starker Einsatzbereich: Bugfixes. Wenn ein Bug gemeldet wird, ist der erste Schritt bei TDD, einen Test zu schreiben, der den Bug reproduziert. Der Test ist rot. Dann wird der Bug gefixt, der Test wird grün. Dieser Test bleibt dauerhaft in der Suite und verhindert, dass derselbe Bug wieder auftritt. Das ist konkrete, nachweisbare Regression-Prevention – kein theoretischer Mehrwert, sondern ein direkt messbarer Effekt im nächsten Deployment.

6. Wo TDD Overhead erzeugt

TDD ist kein universelles Werkzeug. Bei UI-Komponenten, Datenbankschema-Migrationen und dem initialen Setup von Konfigurationsdateien erzeugt es mehr Overhead als Nutzen. Das ist kein Fehler von TDD, sondern eine Frage des Anwendungsbereichs. Magento-Layouts, XML-Konfigurationsdateien und Deployment-Skripte sind typischerweise keine guten TDD-Kandidaten – ihr korrektes Verhalten hängt von der Integration mit dem Gesamtsystem ab, nicht von isolierter Logik.

Auch bei Explorationscode lohnt TDD oft nicht: Wenn man noch nicht weiß, wie die Lösung aussehen wird, ist es schwer, sinnvolle Tests zu schreiben. In solchen Phasen ist Prototyping ohne Tests oft schneller – mit der Maßgabe, dass der ausgereifte Code dann konsequent rückwirkend getestet oder von Grund auf testgetrieben neu geschrieben wird. Die Faustregel: TDD für stabile Anforderungen mit klarer Spezifikation, Prototyping für Exploration, Tests danach für UI und Infrastruktur.

7. TDD in Magento-Projekten pragmatisch

Magento hat eine komplexe Architektur mit tiefem Dependency Injection Container, Event System und Plugin-Mechanismen. Das macht TDD für Magento-spezifische Komponenten schwieriger als für reine PHP-Services. Die pragmatische Strategie: TDD konsequent für die eigene Businesslogik anwenden – Services, Calculator-Klassen, Repositories, Transformer – und für Magento-Framework-Integration auf Integrationstests setzen, die mit dem Container arbeiten.

ViewModels sind ein besonders guter TDD-Kandidat in Magento: Sie enthalten oft Formatierungslogik, Preisberechnung und Datenaufbereitung, die sich vollständig isoliert testen lässt. Ein ViewModel, das Preise formatiert, Verfügbarkeiten berechnet oder Produktdaten strukturiert, hat klare Eingaben und Ausgaben – ideal für TDD. Plugin-Klassen, Observer und Layout-Modifikationen sind dagegen besser mit Integrationstests abgedeckt.

8. TDD im Team einführen ohne Dogmatismus

TDD im Team einzuführen scheitert am häufigsten an zu großem Ehrgeiz: Man erklärt, dass ab sofort jeder Feature-Code mit Tests beginnt, und trifft auf Widerstand von Entwicklern, die unter Zeitdruck stehen und das Gefühl haben, doppelt so lange zu brauchen. Der pragmatische Einstieg: TDD zunächst für Bugfixes verbindlich einführen. Jeder Bug bekommt zuerst einen reproduzierenden Test. Das ist eine kleine, konkrete Veränderung mit sofort sichtbarem Nutzen – der Test zeigt, dass der Bug existiert, und verhindert seine Wiederkehr.

Der nächste Schritt: TDD für neue, isolierte Services und Calculator-Klassen. Diese sind typischerweise frei von Frameworkabhängigkeiten und eignen sich ideal für den Einstieg. Bestehendem Code Tests hinzuzufügen ist oft mühsamer als neuen Code testgetrieben zu entwickeln. Teams, die TDD so einführen – zuerst Bugfixes, dann neue isolierte Logik –, bauen langfristig eine Kultur auf, in der Tests zur selbstverständlichen Praxis werden, statt zur aufgezwungenen Bürde.

9. TDD vs. Test-after im Vergleich

Beide Ansätze haben ihre Berechtigung. Der direkte Vergleich hilft bei der Entscheidung, welchen Ansatz man für welche Aufgabe wählt.

Kriterium TDD (Test-first) Test-after Empfehlung
Design-Feedback Direkt – schlechtes Design zeigt sich sofort Nachgelagert – Design steht bereits fest TDD für neue Services
Geschwindigkeit (kurzfristig) Langsamer durch doppelten Schreibaufwand Schneller im ersten Sprint Test-after für Exploration
Grenzfall-Abdeckung Hoch – Tests definieren Grenzfälle vorab Niedriger – oft nach Bauchgefühl TDD für Businesslogik
Bugfix-Regression Eingebaut – Bug zuerst als Test Möglich, aber nicht erzwungen TDD für alle Bugfixes
Frameworklastiger Code Schwierig ohne Infrastruktur Integrationstests besser geeignet Test-after für Magento-Integration

Mironsoft

PHP-Entwicklung, TDD-Coaching und Magento-Testing

TDD pragmatisch in eurem PHP-Projekt einführen?

Wir helfen Teams, Test-Driven Development realistisch einzuführen – ohne Dogmatismus, mit klaren Grenzen zwischen TDD-Kandidaten und Test-after-Code, und mit konkretem Coaching direkt am Produktionscode.

TDD-Workshop

Hands-on-Workshop mit eurem Produktionscode – Red-Green-Refactor direkt am echten Projekt

Testarchitektur

Klare Trennung von Unit-, Integrations- und Funktionstests für Magento-Projekte

Code-Review

Review bestehender Tests auf Design-Feedback-Qualität und sinnvolle Testabdeckung

10. Zusammenfassung

TDD in PHP ist kein Allheilmittel und kein Mythos, sondern ein Design-Feedback-System, das in bestimmten Kontexten hervorragend funktioniert und in anderen Overhead erzeugt. Der Red-Green-Refactor-Zyklus führt zu klareren Schnittstellen, besserer Grenzfallabdeckung und eingebautem Regressionschutz bei Bugfixes. Für Magento-Projekte bedeutet das: TDD konsequent für Businesslogik, Services und ViewModels – und pragmatische Integrationstests für Frameworkintegration.

Die wichtigste Erkenntnis: TDD funktioniert nur auf testbarem Code. Wer TDD einführen will, muss gleichzeitig die Architektur für Testbarkeit optimieren – Dependency Injection, Repository-Pattern, klare Interfaces. Teams, die das verstehen, berichten nach sechs Monaten konsequenter TDD-Praxis von deutlich weniger Regressionsfehler und besserem Vertrauen in Refactoring. Das ist der reale Mehrwert – messbar, nicht dogmatisch.

TDD in PHP — Das Wichtigste auf einen Blick

Red-Green-Refactor

Test schreiben → fehlschlagen lassen → minimal grün machen → refactoren. Jede Phase strikt getrennt halten.

Beste Einsatzbereiche

Businesslogik, Preisberechnungen, Validierung, Bugfixes. Nicht für UI, Migrationen oder Framework-Integration.

Magento-Strategie

TDD für Services und ViewModels. Integrationstests für DI-Container und Plugin-Mechanismen. Klare Trennung einhalten.

Teameinführung

Mit Bugfixes starten: jeder Bug zuerst als reproduzierender Test. Dann neue isolierte Services. Kein Big-Bang-Ansatz.

11. FAQ: TDD in PHP realistisch anwenden

1Was ist TDD in PHP?
Tests werden vor dem Produktionscode geschrieben. Red-Green-Refactor-Zyklus: Test scheitern lassen, minimal grün machen, dann refactoren – Schritt für Schritt.
2Ist TDD wirklich langsamer?
Kurzfristig ja. Langfristig gleicht sich der Aufwand durch weniger Debugging und Regression aus. Teams berichten nach 3–6 Monaten von höherer Gesamtgeschwindigkeit.
3Bester Einsatzbereich für TDD?
Businesslogik, Berechnungen, Validierung, Bugfixes. Nicht für UI, Datenbankmigrationen oder Frameworkintegration.
4TDD mit Datenbankzugriffen?
Repository-Pattern + Interface. Im Test mit createMock() ersetzen. TDD erzwingt die Trennung von Businesslogik und Infrastruktur.
5TDD in Magento möglich?
Ja, für Services, ViewModels und Calculator-Klassen. Magento-Framework-Integration (Plugins, Events) besser mit Integrationstests.
6createMock() vs. createStub()?
Stub: konfigurierte Rückgabewerte, keine Aufrufsverifikation. Mock: zusätzlich expects()-Assertions für Interaktionsverhalten. Stubs für einfache Abhängigkeiten bevorzugen.
7TDD im Team einführen?
Mit Bugfixes starten: reproduzierender Test zuerst. Dann neue isolierte Services. Kein Big-Bang. Coaching am Produktionscode wirksamer als Theorie.
8Red-Green-Refactor konkret?
Test schreiben → scheitern lassen (Red) → minimalen Code schreiben (Green) → verbessern ohne Verhaltensänderung (Refactor) → Tests nach jedem Schritt ausführen.
9100% Coverage bei TDD notwendig?
Nein. TDD zielt auf sinnvolle Tests, nicht Coverage-Zahlen. Wichtig: alle Grenzfälle und Fehlerszenarien der Businesslogik abdecken.
10TDD blockiert die Entwicklung?
Das ist ein Design-Signal: Code ist nicht testbar. Lösung: Architektur verbessern – Interfaces, DI, Repository-Pattern. TDD aufzugeben löst das Design-Problem nicht.