wo Produktionscode Tests erleichtert
Die häufigste Erfahrung in PHP-Projekten: Tests sind schwierig zu schreiben, weil der Produktionscode es schwierig macht. Neue Abhängigkeiten direkt instanziiert, globaler State, zu viele Aufgaben in einer Klasse. Testbarkeit ist kein Zufallsprodukt – sie ist das Ergebnis konkreter Designentscheidungen im Produktionscode.
Inhaltsverzeichnis
- 1. Testbarkeit als Designqualität – nicht als Nacharbeit
- 2. Constructor Injection: die Grundlage testbarer Klassen
- 3. Single Responsibility: kleine Klassen, einfache Tests
- 4. Pure Functions: deterministische Logik ist sofort testbar
- 5. Value Objects: unveränderliche Daten, triviale Tests
- 6. Interface-Segregation: kleine Interfaces, präzise Mocks
- 7. Testbar vs. nicht testbar: Produktionscode im Direktvergleich
- 8. Zusammenfassung
- 9. FAQ
1. Testbarkeit als Designqualität – nicht als Nacharbeit
Testbarkeit ist keine Eigenschaft, die man nachträglich hinzufügt. Sie ist das Resultat von Designentscheidungen, die beim ersten Schreiben des Codes getroffen werden. Code, der schwer zu testen ist, ist fast immer auch schwer zu warten, zu refactoren und zu verstehen – weil schwierige Testbarkeit ein Symptom von schlecht durchdachten Abhängigkeiten und Verantwortlichkeiten ist. Das Umgekehrte gilt ebenfalls: Code, der leicht zu testen ist, hat in der Regel klare Abhängigkeiten, scharfe Verantwortlichkeiten und ein vorhersagbares Verhalten.
Die gute Nachricht: Die wichtigsten Designmuster für Testbarkeit sind dieselben, die generell als gutes PHP-Design gelten. Constructor Injection statt interner Instanziierung. Single Responsibility statt Gottklassen. Pure Functions für Transformationslogik. Value Objects statt primitiver Datentypen. Interface-Segregation für präzise Mocks. Wer diese Muster konsequent anwendet, schreibt Code, der nicht nur leichter zu testen ist, sondern auch leichter zu verstehen, zu erweitern und zu refactoren ist.
2. Constructor Injection: die Grundlage testbarer Klassen
Constructor Injection ist das fundamentalste Designmuster für Testbarkeit. Wenn eine Klasse alle ihre Abhängigkeiten über den Konstruktor erhält, kann ein Test genau diese Abhängigkeiten durch Mocks ersetzen – ohne den Produktionscode zu ändern. Wenn eine Klasse hingegen Abhängigkeiten intern mit new ClassName() oder über den Magento-ObjectManager instanziiert, ist sie nicht ohne Magento-Bootstrap testbar. Das ist der fundamentale Unterschied zwischen testbarem und nicht-testbarem Code.
PHP 8 macht Constructor Injection durch Constructor Property Promotion noch knapper. Statt manueller Property-Deklarationen, Konstruktor-Parameter und Zuweisungen schreibt man alles in einer Zeile. Das reduziert Boilerplate erheblich und macht die Abhängigkeiten einer Klasse auf den ersten Blick erkennbar. In Magento-Projekten fügt sich Constructor Property Promotion nahtlos in das DI-System ein: der ObjectManager injiziert alle Abhängigkeiten automatisch anhand der Typdeklarationen im Konstruktor.
<?php
declare(strict_types=1);
namespace Mironsoft\Catalog\Model;
use Mironsoft\Catalog\Api\Data\ProductInterface;
use Mironsoft\Catalog\Api\ProductRepositoryInterface;
use Mironsoft\Catalog\Api\TaxCalculatorInterface;
use Psr\Log\LoggerInterface;
/**
* Service for enriching product data with calculated fields.
*
* Design: Constructor Injection for all dependencies.
* All dependencies are interfaces — easily mockable in tests.
* No internal 'new', no ObjectManager calls.
*
* Test setup requires only 3 lines of mock creation.
*/
final class ProductEnrichmentService
{
public function __construct(
private readonly ProductRepositoryInterface $productRepository,
private readonly TaxCalculatorInterface $taxCalculator,
private readonly LoggerInterface $logger,
) {}
/**
* Enrich a product with calculated gross price.
*
* @throws \InvalidArgumentException When product has no valid price
*/
public function enrichWithGrossPrice(int $productId): ProductInterface
{
$product = $this->productRepository->getById($productId);
if ($product->getPrice() === null || $product->getPrice() < 0) {
throw new \InvalidArgumentException(
"Product {$productId} has no valid price for gross calculation"
);
}
$gross = $this->taxCalculator->calculateGross(
net: $product->getPrice(),
taxClass: $product->getTaxClassId()
);
$this->logger->debug("Enriched product {$productId}: net={$product->getPrice()}, gross={$gross}");
return $product->setData('gross_price', $gross);
}
}
Der entscheidende Unterschied zu nicht-testbarem Code: Alle drei Abhängigkeiten (ProductRepositoryInterface, TaxCalculatorInterface, LoggerInterface) sind Interfaces. Im Unit-Test werden alle drei mit $this->createMock() ersetzt. Die Testklasse braucht keinen Magento-Bootstrap, keine Datenbankverbindung und kein echtes Tax-System. Der Test läuft in Millisekunden und prüft genau die Logik dieses Services – die Entscheidung, ob ein gültiger Preis vorliegt, und die Weiterleitung an den Tax-Calculator.
3. Single Responsibility: kleine Klassen, einfache Tests
Das Single Responsibility Principle (SRP) besagt, dass eine Klasse nur einen Grund zur Änderung haben sollte – und damit genau eine klar abgegrenzte Aufgabe. Aus Testsicht hat SRP einen unmittelbaren, praktischen Effekt: Klassen mit einer Aufgabe haben wenig Abhängigkeiten, und Tests für sie haben wenig Setup-Aufwand. Eine Klasse, die Produkte lädt, Preise berechnet, Emails versendet und in Logs schreibt, hat vier Abhängigkeiten zu mocken. Eine Klasse, die nur Preise berechnet, hat null bis eine Abhängigkeit zu mocken.
Das Symptom einer SRP-Verletzung in Tests: der setUp()-Block einer Testklasse ist länger als die Testmethoden selbst. Wenn fünf Mocks erstellt werden müssen, um eine Methode testen zu können, ist das ein starkes Indiz, dass die getestete Klasse zu viele Aufgaben hat. Das Refactoring-Rezept: die Klasse in kleinere Services aufteilen, jeden mit einer einzigen Verantwortung. Das Ergebnis sind nicht nur einfachere Tests, sondern auch besser wartbarer Produktionscode.
<?php
declare(strict_types=1);
namespace Mironsoft\Catalog\Model;
use Mironsoft\Catalog\Api\Data\PriceResultInterface;
/**
* Pure tax calculation service — no external dependencies.
* Single responsibility: calculate gross from net and tax rate.
*
* Test setup: zero mocks needed. Direct instantiation.
* This is the ideal testability case: a stateless service with pure logic.
*/
final class TaxCalculator implements \Mironsoft\Catalog\Api\TaxCalculatorInterface
{
// German VAT rates
private const array TAX_RATES = [
1 => 0.19, // Standard rate
2 => 0.07, // Reduced rate (food, books)
0 => 0.00, // Tax exempt
];
/**
* Calculate gross price from net price and Magento tax class ID.
*
* Pure function: same input always produces same output.
* No side effects, no I/O, no state.
*
* @throws \InvalidArgumentException For unknown tax class IDs
*/
public function calculateGross(float $net, int $taxClass): float
{
if (!isset(self::TAX_RATES[$taxClass])) {
throw new \InvalidArgumentException(
"Unknown tax class ID: {$taxClass}. Valid: " . implode(', ', array_keys(self::TAX_RATES))
);
}
if ($net < 0.0) {
throw new \InvalidArgumentException("Net price must be non-negative, got: {$net}");
}
return round($net * (1.0 + self::TAX_RATES[$taxClass]), 2);
}
/**
* Calculate net price from gross price and tax class ID.
* Inverse of calculateGross — round-trip should be identity within rounding.
*/
public function calculateNet(float $gross, int $taxClass): float
{
if (!isset(self::TAX_RATES[$taxClass])) {
throw new \InvalidArgumentException("Unknown tax class ID: {$taxClass}");
}
return round($gross / (1.0 + self::TAX_RATES[$taxClass]), 2);
}
}
4. Pure Functions: deterministische Logik ist sofort testbar
Eine pure Function (reine Funktion) gibt für dieselbe Eingabe immer dieselbe Ausgabe zurück und hat keine Seiteneffekte. Sie liest keinen globalen State, schreibt nicht in Dateien oder Datenbanken und sendet keine HTTP-Requests. Diese Eigenschaft macht sie trivial testbar: kein Mock, kein Setup, keine Fixtures. Ein Aufruf mit bekannten Eingaben, ein Assert auf die Ausgabe – fertig.
In PHP-Services bedeutet das konkret: Transformationslogik, Formatierung, Berechnungen und Validierung sollten als stateless Methods oder als eigene Service-Klassen ohne externe Abhängigkeiten implementiert werden. Wenn eine Methode Datenbankzugriff braucht, um einen Preis zu berechnen, ist das eine Verletzung der Pure-Function-Eigenschaft – und gleichzeitig ein Testbarkeitsproblem. Die Lösung: den Datenbankzugriff (laden des Produkts) von der Berechnung (gross aus net berechnen) trennen. Der Ladevorgang wird gemockt, die Berechnung ist eine pure Function, die direkt getestet werden kann.
5. Value Objects: unveränderliche Daten, triviale Tests
Value Objects sind unveränderliche Objekte, die nur durch ihren Wert identifiziert werden, nicht durch ihre Identität. Eine Geldmenge, eine Produktkategorie, eine SKU, eine E-Mail-Adresse – das sind typische Value Objects. Sie haben keine externe Abhängigkeit, kapseln ihre Validierung im Konstruktor und bieten keine Setter. Das macht sie ideal testbar: direkt instanziierbar, kein Mock nötig, vollständiges Verhalten ohne externe Systeme.
Value Objects verbessern auch die Testlesbarkeit. Statt assertSame(119.0, $result) schreibt man assertEquals(Money::ofEur(119.0), $result). Der Test kommuniziert direkt die Domänenkonzepte. Value Objects mit equals()-Methode machen Vergleiche semantisch korrekt – zwei Money-Objekte mit demselben Betrag und derselben Währung sind gleich, auch wenn sie verschiedene Instanzen sind. PHP 8 readonly Classes und readonly Properties machen die Implementierung von Value Objects ohne zusätzlichen Aufwand unveränderlich.
<?php
declare(strict_types=1);
namespace Mironsoft\Catalog\Model\ValueObject;
/**
* Value Object for monetary amounts.
* Immutable, self-validating, directly testable without any mocks.
*
* PHP 8.2+ readonly class — all properties automatically readonly.
*/
final readonly class Money
{
/**
* @throws \InvalidArgumentException When amount is negative or currency is invalid
*/
public function __construct(
public readonly float $amount,
public readonly string $currency,
) {
if ($amount < 0.0) {
throw new \InvalidArgumentException("Money amount must be non-negative, got: {$amount}");
}
if (!in_array($currency, ['EUR', 'USD', 'GBP', 'CHF'], true)) {
throw new \InvalidArgumentException("Unknown currency: {$currency}");
}
}
public static function ofEur(float $amount): self
{
return new self(amount: $amount, currency: 'EUR');
}
/** Add two monetary values — only possible if same currency. */
public function add(self $other): self
{
if ($this->currency !== $other->currency) {
throw new \InvalidArgumentException(
"Cannot add {$this->currency} and {$other->currency}"
);
}
return new self(amount: round($this->amount + $other->amount, 2), currency: $this->currency);
}
/** Apply a percentage: 0.19 = 19% */
public function applyPercentage(float $percentage): self
{
return new self(
amount: round($this->amount * (1.0 + $percentage), 2),
currency: $this->currency
);
}
public function equals(self $other): bool
{
return $this->currency === $other->currency
&& abs($this->amount - $other->amount) < 0.001;
}
public function __toString(): string
{
return number_format($this->amount, 2) . ' ' . $this->currency;
}
}
6. Interface-Segregation: kleine Interfaces, präzise Mocks
Das Interface Segregation Principle (ISP) fordert, dass Interfaces klein und spezifisch sein sollen – jeder Client implementiert nur, was er tatsächlich braucht. Aus Testsicht hat das einen direkten Vorteil: kleine Interfaces sind einfach zu mocken. Ein Mock für ein Interface mit fünf Methoden muss alle fünf Methods konfigurieren oder mit willReturn(null) abdecken. Ein Mock für ein Interface mit einer Methode konfiguriert genau die eine Methode, die der Test benötigt.
In Magento-Projekten sieht man häufig große Interfaces wie ProductInterface mit Dutzenden Methoden. Für Services, die nur getSku() und getPrice() benötigen, ist ein separates Interface PriceableProductInterface mit genau diesen zwei Methoden die testbarere Lösung. Der Service deklariert die Abhängigkeit zum kleinen Interface; der Mock implementiert nur die zwei Methoden. Das Ergebnis: präzisere Mocks, klarere Tests und Produktionscode, der explizit kommuniziert, welche Fähigkeiten er von seinen Abhängigkeiten braucht.
7. Testbar vs. nicht testbar: Produktionscode im Direktvergleich
Die folgenden Gegenüberstellungen zeigen konkret, wie Produktionscode-Entscheidungen die Testbarkeit bestimmen. Die linke Spalte zeigt Muster, die Tests erschweren oder unmöglich machen; die rechte Spalte zeigt die testbare Alternative.
| Muster | Nicht testbar | Testbar | Testaufwand-Reduktion |
|---|---|---|---|
| Abhängigkeiten | new Dependency() im Konstruktor |
Constructor Injection via Interface | Kein Bootstrap, kein echtes Objekt nötig |
| Verantwortung | 5+ Methoden, 5+ Abhängigkeiten | Eine Aufgabe, 1–2 Abhängigkeiten | setUp() in 2 Zeilen statt 15 |
| Seiteneffekte | DB + Log + Mail in einer Methode | Pure Berechnungslogik extrahiert | Pure Function: null Mocks, null Setup |
| Datenübergabe | Primitive (float $price, string $currency) | Value Object (Money $price) | Typsicherheit, selbstvalidierend |
| Interface-Größe | 50-Methoden-Interface mocken | Präzises 2-Methoden-Interface | Mock konfiguriert nur genutzten Bereich |
Jede Zeile in dieser Tabelle ist eine konkrete Designentscheidung mit messbarem Einfluss auf den Testaufwand. Teams, die systematisch nach diesen Mustern bauen, berichten nach 3–6 Monaten, dass das Schreiben neuer Tests signifikant schneller wird – nicht weil sich die Tests geändert haben, sondern weil der Produktionscode leichter testbar ist. Testbarkeit ist kein Selbstzweck: sie ist ein verlässlicher Indikator für gutes Software-Design.
Mironsoft
Testbares PHP-Design, Clean Code und Refactoring für Magento-Teams
Produktionscode testbar machen?
Wir analysieren euren bestehenden PHP-Code, identifizieren die zentralen Testbarkeitsprobleme und refactoren Services, ViewModels und Repositories zu testbarem Clean Code – mit phpdoc, Constructor Injection und klaren Interface-Grenzen.
Code-Analyse
Testbarkeitsprobleme identifizieren: interne Instanziierung, Gottklassen, globaler State
Refactoring
Constructor Injection, SRP, Value Objects und Interface-Segregation einführen
Team-Coaching
Designmuster für Testbarkeit im Team etablieren, Code-Reviews strukturieren
8. Zusammenfassung
Testbare Services zu bauen ist keine separate Aufgabe nach dem Schreiben von Produktionscode – es ist Teil des Schreibens von gutem Produktionscode. Die fünf wichtigsten Designmuster sind klar: Constructor Injection für alle Abhängigkeiten, damit Tests sie durch Mocks ersetzen können. Single Responsibility für kleine, fokussierte Klassen, die wenig Mock-Setup brauchen. Pure Functions für Transformationslogik, die ohne jeden Mock testbar ist. Value Objects für unveränderliche Domänenwerte, die direkt instanziierbar und selbstvalidierend sind. Interface-Segregation für präzise, minimal Mocks.
Das Indikator-Prinzip: Wenn eine Testklasse mehr als 5 Mocks erstellen muss, ist der Produktionscode wahrscheinlich zu komplex. Wenn ein setUp()-Block länger als 20 Zeilen ist, ist die getestete Klasse wahrscheinlich zu groß. Wenn ein Test ohne den vollständigen Magento-Bootstrap nicht funktioniert, greift der Produktionscode direkt auf Magento-Infrastruktur zu, statt Abstraktionen zu nutzen. Diese Warnsignale zeigen Designprobleme im Produktionscode an – nicht Schwächen in den Tests. Das Richtige ist dann nicht mehr Tests zu schreiben, sondern den Produktionscode zu refactoren.
Testbare Services bauen — Das Wichtigste auf einen Blick
Constructor Injection
Alle Abhängigkeiten über den Konstruktor als Interfaces. Kein new, kein ObjectManager direkt. Erlaubt vollständige Mock-Kontrolle in Tests.
Single Responsibility
Eine Klasse, eine Aufgabe. Test-setUp() mit mehr als 5 Mocks ist ein Warnsignal. Klasse aufteilen und jede kleinere Klasse einzeln testen.
Pure Functions & Value Objects
Berechnungen in pure Methoden extrahieren. Value Objects für Domänenwerte – direkt instanziierbar, selbstvalidierend, kein Mock nötig.
Interface-Segregation
Kleine, präzise Interfaces statt großer Allzweck-Interfaces. Mocks konfigurieren nur die Methoden, die der Test tatsächlich braucht.