Wann welcher Testtyp in PHP-Projekten?
Die meisten PHP-Projekte haben entweder zu viele Unit Tests für Code, der von Datenbankzuständen abhängt, oder zu wenige Tests auf der richtigen Ebene. Die Testpyramide ist kein akademisches Konzept, sondern ein praktisches Werkzeug für die Entscheidung, welcher Testtyp welches Problem am effizientesten löst.
Inhaltsverzeichnis
- 1. Die Testpyramide: Warum das Verhältnis wichtig ist
- 2. Unit Tests: Isolierte Logik ohne externe Abhängigkeiten
- 3. Integrationstests: Zusammenspiel mit echter Infrastruktur
- 4. Functional Tests: Verhalten aus Nutzerperspektive
- 5. Grenzen des Mockings: Wann Mocks schaden
- 6. Testtypen in Magento 2: Besonderheiten und Empfehlungen
- 7. Entscheidungsbaum: Welcher Test für welches Problem?
- 8. Häufige Fehler bei der Testtypenwahl
- 9. Testtypen im direkten Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Die Testpyramide: Warum das Verhältnis wichtig ist
Die Testpyramide ist ein Modell, das Martin Fowler und Mike Cohn popularisiert haben. Es besagt, dass ein gut aufgestelltes PHP-Projekt viele schnelle Unit Tests, eine moderate Anzahl Integrationstests und wenige langsame Functional Tests haben sollte. Das Verhältnis ist keine starre Regel, sondern ein Feedback-Signal: Wenn ein Projekt hauptsächlich aus langsamen Integrationstests besteht, ist das ein Hinweis auf mangelnde Testbarkeit des Produktionscodes. Wenn es hauptsächlich Unit Tests mit intensivem Mocking hat, ist das ein Hinweis darauf, dass die Tests das System nicht ausreichend unter echten Bedingungen testen.
In PHP-Projekten, besonders in Magento 2, sieht man häufig die umgekehrte Pyramide: Viele Integration- und Functional Tests, wenige Unit Tests. Der Grund dafür ist historisch: Magento-Code war lange schwer testbar, weil er direkt vom ObjectManager und globalen Zuständen abhängig war. Moderne Magento-Module mit ViewModels, Service-Contracts und Repositories sind deutlich besser unit-testbar. Die Testpyramide ist also auch ein Architekturkritikum: Schlechte Testbarkeit erzwingt höhere, langsamere Tests.
2. Unit Tests: Isolierte Logik ohne externe Abhängigkeiten
Ein Unit Test testet eine einzelne Klasse oder eine einzelne Funktion vollständig isoliert von ihrer Umgebung. Alle Abhängigkeiten werden durch Test-Doubles (Mocks, Stubs, Spies) ersetzt. Das Ergebnis: Unit Tests sind extrem schnell (Millisekunden), reproduzierbar und liefern präzises Feedback, welche Logik fehlerhaft ist. Sie testen keine Datenbankabfragen, keine HTTP-Requests und keinen Filesystem-Zustand.
Der entscheidende Fehler beim Schreiben von Unit Tests ist zu viel Mocking. Wenn eine Klasse zehn Abhängigkeiten hat und alle zehn gemockt werden müssen, damit der Test läuft, ist das ein Signal, dass die Klasse zu viele Verantwortlichkeiten hat. Ein guter Unit Test ist einfach zu schreiben und zu lesen – er testet eine überschaubare Logik mit wenigen, klar verständlichen Assertions.
<?php
// Unit test: tests pure business logic in isolation
// No database, no HTTP, no filesystem — just the calculation
declare(strict_types=1);
namespace Mironsoft\Pricing\Test\Unit\Service;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\DataProvider;
use Mironsoft\Pricing\Service\TaxCalculator;
final class TaxCalculatorTest extends TestCase
{
private TaxCalculator $calculator;
protected function setUp(): void
{
$this->calculator = new TaxCalculator();
}
#[DataProvider('taxRateProvider')]
public function testCalculatesTaxCorrectly(
float $net,
float $rate,
float $expectedGross
): void {
$result = $this->calculator->addTax($net, $rate);
self::assertEqualsWithDelta($expectedGross, $result, 0.001);
}
public static function taxRateProvider(): array
{
return [
'standard German VAT 19%' => [100.00, 19.0, 119.00],
'reduced German VAT 7%' => [100.00, 7.0, 107.00],
'zero-rated export' => [100.00, 0.0, 100.00],
'zero net price' => [0.00, 19.0, 0.00],
];
}
public function testThrowsOnNegativeTaxRate(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Tax rate cannot be negative');
$this->calculator->addTax(100.00, -5.0);
}
}
Dieses Beispiel illustriert, was einen guten Unit Test ausmacht: Die TaxCalculator-Klasse hat keine externen Abhängigkeiten. Der Test braucht kein Mocking. DataProvider decken mehrere Fälle in einem Test ab. Die Assertions sind präzise und kommunizieren das erwartete Verhalten. Der Test läuft in unter einer Millisekunde und gibt bei Fehlern sofort an, welche Eingabe welches falsche Ergebnis erzeugt hat.
3. Integrationstests: Zusammenspiel mit echter Infrastruktur
Ein Integrationstest testet das Zusammenspiel mehrerer Komponenten – typischerweise mit echter Datenbankverbindung, echten Repositories und echten Services. Er testet nicht, ob die SQL-Abfrage syntaktisch korrekt ist, sondern ob die gesamte Kette vom Service über das Repository bis zur Datenbank korrekt funktioniert. Integrationstests sind langsamer als Unit Tests (Sekunden statt Millisekunden), aber sie testen das System unter realistischeren Bedingungen.
Der typische Bereich für Integrationstests in PHP-Projekten: Repository-Implementierungen, Service-Klassen die Repositories kombinieren, Event-Listener die Datenbankzustand verändern, und komplexe Query-Builder-Logik. Alles, was von einem bestimmten Datenbankzustand abhängt, ist ein Kandidat für einen Integrationstest – nicht für einen Unit Test mit Mock-Repositories.
4. Functional Tests: Verhalten aus Nutzerperspektive
Functional Tests (auch End-to-End-Tests oder Acceptance Tests genannt) testen das System wie ein Endnutzer: Sie senden HTTP-Requests, lesen HTTP-Responses und prüfen das Verhalten aus der Außenperspektive. Sie testen nicht, welche Klasse welche Methode aufruft, sondern ob das System auf eine bestimmte Eingabe die korrekte Ausgabe produziert. In Magento 2 entsprechen Functional Tests typischerweise MFTF-Tests (Magento Functional Testing Framework) oder PHPUnit-Tests, die HTTP-Requests über den Magento-Test-Client absetzen.
Functional Tests sind die langsamsten Tests in der Pyramide: Sie brauchen einen vollständig initialisierten Magento-Stack, echte Datenbankverbindungen und oft auch einen laufenden Webserver. Der Bereich, für den sie unverzichtbar sind: Checkout-Flows, Zahlungsintegration, mehrstufige Formulare und alle Bereiche, wo das korrekte Rendering von Templates zum Testergebnis gehört. Für diese Bereiche sind Unit und Integrationstests nicht ausreichend.
<?php
// Functional test via PHPUnit + Magento HTTP test client
// Tests the full checkout flow from cart to order
declare(strict_types=1);
namespace Mironsoft\Checkout\Test\Integration\Controller;
use Magento\TestFramework\TestCase\AbstractController;
use Magento\TestFramework\Helper\Bootstrap;
use Mironsoft\Checkout\Test\Integration\DataBuilder\ProductBuilder;
use Mironsoft\Checkout\Test\Integration\DataBuilder\CustomerBuilder;
class CheckoutControllerTest extends AbstractController
{
/**
* Tests that adding a product to cart returns the correct response.
*
* @magentoConfigFixture default_store general/locale/code de_DE
*/
public function testAddToCartReturnsSuccessResponse(): void
{
$product = ProductBuilder::aProduct()
->withSku('checkout-test-001')
->withPrice(49.99)
->build();
$this->dispatch('checkout/cart/add/product/' . $product->getId() . '/qty/1');
$this->assertSessionMessages(
$this->containsEqual(
'You added checkout-test-001 to your shopping cart.'
)
);
$this->assertRedirect($this->stringContains('checkout/cart'));
}
/**
* Tests that the checkout summary displays correct totals.
*/
public function testCheckoutSummaryDisplaysCorrectTotals(): void
{
// Full integration: real cart, real totals calculation
$response = $this->getRequest()->setMethod('GET');
$this->dispatch('checkout/');
$this->assertResponseCode(200);
$body = $this->getResponse()->getBody();
self::assertStringContainsString('Grand Total', $body);
}
}
5. Grenzen des Mockings: Wann Mocks schaden
Mocks sind das wichtigste Werkzeug für Unit Tests, aber sie haben klare Grenzen. Ein Mock ersetzt eine echte Implementierung durch eine vorprogrammierte Attrappe. Das Problem: Ein Mock, der eine falsche Annahme über das Verhalten der echten Klasse implementiert, lässt Tests grün werden, obwohl der echte Code fehlerhaft ist. Je komplexer das Verhalten einer Abhängigkeit, desto gefährlicher ist das Mocking.
Die praktische Faustregel: Repositories und externe Services (HTTP-Clients, E-Mail-Sender, Payment-Gateways) mocken – immer. Eigene Domain-Objekte und Value-Objects nicht mocken – echte Instanzen verwenden. Magento-Core-Services (ProductRepository, CustomerRepository) in Unit Tests mocken, aber in Integrationstests nicht. Das Ergebnis einer Entscheidung, zu viel zu mocken, ist eine Test-Suite, die grün ist, aber kaum echte Sicherheit gibt.
6. Testtypen in Magento 2: Besonderheiten und Empfehlungen
Magento 2 hat ein eigenes Test-Framework, das drei Ebenen kennt: Unit Tests (in Test/Unit/), Integration Tests (in Test/Integration/) und MFTF-Tests (functional). Für die Unit-Test-Ebene gilt: Alle Klassen, die reine Business-Logik implementieren – Preisberechnungen, Validierungen, Transformationen – sind Unit-testbar, wenn sie korrekt über Constructor-Injection aufgebaut sind. ViewModels, die keine direkten Datenbankabfragen durchführen, sind ideal für Unit Tests geeignet.
Für Magento-Integrationstests gilt: Event-Listener, Plugins, Repository-Implementierungen und komplexe Service-Kombinationen testen sich am besten in Integration Tests. Diese Tests brauchen den Magento-Bootstrap und eine Test-Datenbank, sind dafür aber realistischer als Unit Tests mit Mock-Objekten. Die MFTF-Ebene eignet sich für kritische User-Flows, sollte aber auf das Minimum beschränkt bleiben, weil MFTF-Tests extrem langsam sind und eine hohe Wartungsaufwand haben.
| Merkmal | Unit Test | Integration Test | Functional Test |
|---|---|---|---|
| Laufzeit | < 1ms | 0.1–10s | 5–60s |
| Isolation | Vollständig | Teilweise | Keine (echtes System) |
| Datenbankzugriff | Kein | Ja (Testdatenbank) | Ja (vollständig) |
| Feedback-Präzision | Sehr hoch | Mittel | Niedrig (viele Ursachen möglich) |
| Wartungsaufwand | Niedrig | Mittel | Hoch |
| Empfohlener Anteil | 70–80% | 15–25% | 5–10% |
7. Entscheidungsbaum: Welcher Test für welches Problem?
Die praktische Entscheidung, welcher Testtyp für welches Problem geeignet ist, folgt einer klaren Logik. Hängt die zu testende Logik von einer Datenbankabfrage ab? Dann kein Unit Test – entweder den Code so refaktorieren, dass die Datenbanklogik in einem Repository isoliert ist (das Repository wird gemockt, die Logik wird unit-getestet), oder einen Integrationstest schreiben. Testet man das Zusammenspiel mehrerer Klassen über echte Interfaces? Integrationstest. Testet man ein User-Facing-Feature, das Rendering und Datenbankzustand kombiniert? Functional Test.
Der wichtigste Hinweis: Die Entscheidung für einen bestimmten Testtyp ist auch eine Architekturentscheidung. Code, der nur mit Integrationstests testbar ist, ist oft schlecht strukturiert. ViewModels, die keine Datenbankabfragen direkt durchführen und alle Abhängigkeiten über den Konstruktor injiziert bekommen, können vollständig unit-getestet werden. Das Ergebnis ist schnelleres Feedback, einfachere Tests und bessere Architektur.
8. Häufige Fehler bei der Testtypenwahl
Der häufigste Fehler: Integrationstests schreiben, obwohl der Code eigentlich unit-testbar wäre. Das passiert, wenn Klassen den ObjectManager direkt nutzen, Singleton-Pattern verwenden oder globale Zustände lesen. Die Lösung liegt nicht im Test-Design, sondern im Refactoring des Produktionscodes. Ein zweiter häufiger Fehler: Functional Tests für Logik schreiben, die mit einem Integrationstest effizienter getestet werden könnte. Functional Tests für Preisberechnungen, die keine Template-Rendering-Aspekte testen müssen, sind überdimensioniert.
<?php
// WRONG: Integration test for pure logic that should be a Unit Test
// This test starts the full Magento stack to test a simple calculation
use Magento\TestFramework\TestCase\AbstractController;
class WrongTaxTest extends AbstractController // full Magento bootstrap!
{
public function testTaxCalculation(): void
{
// Pure math — no database needed, but we're paying full bootstrap cost
$result = 100.00 * 1.19;
self::assertEqualsWithDelta(119.00, $result, 0.001);
}
}
// RIGHT: Pure Unit Test — no bootstrap, runs in <1ms
use PHPUnit\Framework\TestCase;
final class CorrectTaxTest extends TestCase // plain PHPUnit, no Magento
{
public function testTaxCalculation(): void
{
$calculator = new TaxCalculator();
$result = $calculator->addTax(100.00, 19.0);
self::assertEqualsWithDelta(119.00, $result, 0.001);
}
}
9. Testtypen im direkten Vergleich
Jeder Testtyp hat seinen optimalen Einsatzbereich. Die Herausforderung liegt nicht darin, die Typen zu kennen, sondern darin, im Projektalltag konsequent den richtigen Typ für das jeweilige Problem zu wählen. Die Tabelle zeigt die Entscheidungsgrundlagen für PHP-Projekte mit Magento-Kontext.
10. Zusammenfassung
Die Testpyramide ist kein akademisches Konzept, sondern ein praktisches Feedback-Instrument. Ein PHP-Projekt, das hauptsächlich Integrationstests hat, zeigt damit an, dass der Produktionscode schlecht testbar ist. Ein Projekt mit vielen Unit Tests, die intensives Mocking erfordern, zeigt, dass die Klassen zu viele Verantwortlichkeiten haben. Die Testpyramide spiegelt die Architekturqualität wider.
Unit Tests sind schnell, präzise und geben direktes Feedback. Integrationstests testen das Zusammenspiel mit echter Infrastruktur. Functional Tests testen das System wie ein Nutzer. Alle drei Ebenen sind notwendig – aber in der richtigen Proportion. Für Magento 2 bedeutet das: ViewModels und Services unit-testen, Repositories und Event-Listener integrationstest, kritische User-Flows funktional testen.
Unit vs. Integration vs. Functional Test — Das Wichtigste auf einen Blick
Unit Tests (70–80%)
Reine Logik isoliert testen. Keine Datenbankverbindung, kein HTTP. Abhängigkeiten mocken. Läuft in Millisekunden. Gibt präzises Feedback auf einzelne Klassen.
Integrationstests (15–25%)
Zusammenspiel mit echter Infrastruktur. Repositories, Event-Listener, Plugins. Echte Testdatenbank mit Rollback-Isolation. Läuft in Sekunden.
Functional Tests (5–10%)
Kritische User-Flows aus Nutzerperspektive. Vollständiger Stack. Nur wo Unit und Integration nicht ausreichen. MFTF oder HTTP-Client. Läuft in Minuten.
Architektur-Feedback
Schwer testbarer Code ist schlecht strukturierter Code. Wenn nur Integrationstests möglich sind, ist Refactoring des Produktionscodes die Lösung – nicht mehr Mocking.