@test
assert
PHPUnit · Testbarkeit · Clean Code · SOLID · Magento
Testbare Services bauen
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.

19 Min. Lesezeit Constructor Injection · SRP · Pure Functions · Value Objects · Interface Segregation PHPUnit 10/11 · PHP 8.4 · Magento 2.4.x

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.

9. FAQ: Testbare Services in PHP

1Was macht eine PHP-Klasse schwer testbar?
Abhängigkeiten intern mit new instanziieren, statische Methoden mit State, zu viele Aufgaben, direkter ObjectManager-Zugriff. Jede dieser Eigenschaften macht Mocks unmöglich oder sehr aufwändig.
2Constructor Property Promotion für Tests?
Macht Abhängigkeiten sofort sichtbar, reduziert Boilerplate. Kein Einfluss auf Testbarkeit selbst – aber klarere Klassen bedeuten klarere Tests.
3Wann ist eine Klasse zu groß für Unit-Tests?
setUp() braucht mehr als 5 Mocks. Testklasse ist länger als Produktionsklasse. Mocks werden konfiguriert aber nicht aufgerufen. Signal: Produktionsklasse aufteilen.
4Was ist eine Pure Function?
Gleiche Eingabe = gleiche Ausgabe, keine Seiteneffekte. Null Mocks, zero Setup im Test. Eingabe rein, Ausgabe prüfen – fertig.
5Value Object vs. DTO?
Value Object: unveränderlich, selbstvalidierend, Identität über Wert. DTO: mutable Datenbehälter ohne Logik. Value Objects sind direkter testbar.
6Schrittweises Refactoring zu testbarem Code?
Erst Charakterisierungstests für bestehendes Verhalten. Dann Abhängigkeiten extrahieren, Interfaces einführen, new durch Constructor Injection ersetzen. Tests sichern Verhalten während des Refactorings.
7ObjectManager-Aufrufe verhindern?
Als Code-Smell behandeln, PHPStan-Regel einrichten. Factories für dynamische Erstellung nutzen – Factories selbst über Constructor Injection übergeben und in Tests mockbar.
8Statische Methoden testen?
Statische Methoden mit State oder externen Abhängigkeiten in Instanzmethoden umwandeln. Reine statische Utility-Methoden (kein State, kein I/O) sind direkt testbar.
9Dependency Injection vs. Service Locator?
DI: Abhängigkeiten werden von außen übergeben – testbar. Service Locator: Klasse holt sich Abhängigkeiten aktiv (ObjectManager::get()) – verbirgt Abhängigkeiten, erschwert Tests.
10Interface-Segregation in Magento anwenden?
Eigenes kleines Interface für Dienste definieren, die nur Teile eines großen Magento-Interface brauchen. ReadableProductRepositoryInterface mit getById() statt vollständigem ProductRepositoryInterface.