Interceptors isolieren, Observer extrahieren, EventManager mocken
Magento-Plugins und Observer sind mächtige Erweiterungspunkte, aber ihre enge Kopplung an den Magento-ObjectManager macht sie schwer testbar. Mit dem richtigen Refactoring-Ansatz lässt sich die Kernlogik aus jedem Plugin oder Observer extrahieren und isoliert mit PHPUnit testen – ganz ohne Magento-Bootstrap.
Inhaltsverzeichnis
- 1. Das Testbarkeits-Problem bei Magento-Plugins
- 2. Anatomie eines Magento-Plugins
- 3. Logik aus Plugins extrahieren
- 4. Plugin-Klasse mit PHPUnit testen
- 5. Observer-Struktur für Testbarkeit
- 6. EventManager und Event-Objekte mocken
- 7. Around-Plugins sicher testen
- 8. Integration mit Magento-Integrationstests
- 9. Teststrategien im Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Das Testbarkeits-Problem bei Magento-Plugins
Magento 2 verwendet einen Interceptor-Mechanismus für Plugins, der zur Laufzeit automatisch generierte Proxy-Klassen erzeugt. Diese generierten Klassen stehen in Unit-Tests ohne vollständigen Magento-Bootstrap nicht zur Verfügung. Wenn Plugin-Logik direkt in der Plugin-Klasse implementiert ist und diese Klasse tief auf ObjectManager, Registry oder generierte Interfaces angewiesen ist, ist ein isolierter Unit-Test schlicht nicht möglich. Das ist der Ursprung des häufigen Missverständnisses, Magento-Code sei grundsätzlich nicht testbar.
Die Lösung liegt nicht im Testing-Framework, sondern im Plugin-Design. Das Kernprinzip: Die fachliche Logik eines Plugins wird nicht im Plugin selbst implementiert, sondern in einer eigenständigen Serviceklasse. Das Plugin delegiert nur an diesen Service. Die Serviceklasse kennt weder den ObjectManager noch Magento-spezifische Klassen und ist daher vollständig isoliert testbar. Das Plugin selbst bleibt dünn – im Idealfall fünf Zeilen abzüglich der Delegierung.
Dieselbe Logik gilt für Observer. Der Observer erhält ein Observer-Objekt mit dem Event, extrahiert daraus die Daten und übergibt sie an einen Service. Der Observer selbst enthält keine fachliche Logik, nur Datenzugriff und Delegation. Mit diesem Muster ist der Observer ebenfalls isoliert testbar, weil das Event-Objekt leicht gemockt werden kann und der Service separat getestet wird.
2. Anatomie eines Magento-Plugins
Ein Magento-Plugin ist eine gewöhnliche PHP-Klasse, die durch die di.xml-Konfiguration an eine andere Klasse gebunden wird. Plugins können drei Arten von Methoden definieren: before{MethodName} wird vor der Originalmethode aufgerufen und kann Argumente modifizieren. after{MethodName} wird nach der Originalmethode aufgerufen und kann das Ergebnis modifizieren. around{MethodName} umhüllt die originale Methode vollständig und bekommt ein callable $proceed-Argument, mit dem die Originalmethode aufgerufen werden kann.
Around-Plugins sind mächtig, aber problematisch: Sie können die Originalmethode versehentlich nicht aufrufen, und sie erschweren das Debugging erheblich, wenn mehrere Around-Plugins auf dieselbe Methode wirken. Für Tests bedeutet around-Plugins, dass das $proceed-Callable gemockt oder mit einer Closure simuliert werden muss. Für die meisten Anwendungsfälle sind before- und after-Plugins vorzuziehen, weil sie einfacher zu testen und weniger fehleranfällig sind.
<?php
declare(strict_types=1);
namespace Mironsoft\Catalog\Plugin;
use Magento\Catalog\Model\Product;
use Mironsoft\Catalog\Service\PriceModifierService;
/**
* Plugin for Magento Product — delegates to PriceModifierService.
*
* The plugin itself contains no business logic.
* All logic lives in PriceModifierService, which is unit-testable.
*/
class ProductPricePlugin
{
public function __construct(
private readonly PriceModifierService $priceModifier
) {}
/**
* Modify price after getPrice() is called on Product.
*
* @param Product $subject
* @param float|null $result
* @return float|null
*/
public function afterGetPrice(Product $subject, float|null $result): float|null
{
if ($result === null) {
return null;
}
return $this->priceModifier->applyModifications(
price: $result,
productType: $subject->getTypeId(),
customerGroupId: (int) $subject->getCustomerGroupId()
);
}
}
3. Logik aus Plugins extrahieren
Der erste Schritt zur testbaren Plugin-Architektur ist das konsequente Extrahieren der Geschäftslogik in einen dedizierten Service. Dieser Service hat keine Kenntnis davon, dass er von einem Plugin aufgerufen wird. Er bekommt skalare Werte oder Value Objects als Parameter und gibt skalare Werte oder Value Objects zurück. Er hat keine Abhängigkeit auf Magento-Klassen wie Product, Order oder Quote – zumindest nicht auf diejenigen, die den ObjectManager benötigen, um instanziiert zu werden.
Wenn der Service doch auf Magento-Klassen angewiesen ist, werden diese als Interfaces deklariert, die in Tests leicht gemockt werden können. Ein PriceModifierService, der auf CustomerGroupRepositoryInterface angewiesen ist, um Gruppenrabatte nachzuschlagen, bekommt dieses Repository als Konstruktor-Parameter injiziert. Im Test wird das Repository durch einen Mock ersetzt, der kontrollierte Testwerte zurückgibt.
Das Resultat: Der Service ist vollständig isoliert testbar. Das Plugin ist zu dünn, um eigene Tests zu benötigen – seine einzige Aufgabe ist die Delegation. Wenn der Service korrekt funktioniert, funktioniert auch das Plugin. Ein einzelner Integrationstest, der prüft, ob das Plugin tatsächlich greift, reicht für das Plugin selbst aus.
4. Plugin-Klasse mit PHPUnit testen
Auch wenn das Plugin dünn ist, kann es sinnvoll sein, es zu testen – insbesondere das Zusammenspiel von Plugin und Service. In einem Unit-Test wird der Service gemockt, das Subject-Objekt (z.B. Product) ebenfalls. Der Test prüft, ob das Plugin den Service mit den richtigen Parametern aufruft und ob das Ergebnis des Services korrekt weitergegeben wird.
PHPUnit bietet dafür die createMock()-Methode, die eine Stub-Implementierung des Interface oder der Klasse erstellt. Mit expects($this->once())->method('applyModifications')->with(...)->willReturn(...) wird exakt festgelegt, welche Methode mit welchen Argumenten aufgerufen werden soll und welcher Wert zurückgegeben wird. Das ist eine präzise Verhaltensspezifikation, keine loose Prüfung.
<?php
declare(strict_types=1);
namespace Mironsoft\Tests\Unit\Catalog\Plugin;
use Magento\Catalog\Model\Product;
use Mironsoft\Catalog\Plugin\ProductPricePlugin;
use Mironsoft\Catalog\Service\PriceModifierService;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\MockObject\MockObject;
/**
* Unit test for ProductPricePlugin.
*
* Tests delegation to PriceModifierService without Magento bootstrap.
*/
final class ProductPricePluginTest extends TestCase
{
private PriceModifierService&MockObject $priceModifier;
private Product&MockObject $product;
private ProductPricePlugin $plugin;
protected function setUp(): void
{
$this->priceModifier = $this->createMock(PriceModifierService::class);
$this->product = $this->createMock(Product::class);
$this->plugin = new ProductPricePlugin($this->priceModifier);
}
#[\PHPUnit\Framework\Attributes\Test]
public function delegatesToPriceModifierWithCorrectArguments(): void
{
$this->product->method('getTypeId')->willReturn('simple');
$this->product->method('getCustomerGroupId')->willReturn('1');
$this->priceModifier
->expects($this->once())
->method('applyModifications')
->with(price: 99.99, productType: 'simple', customerGroupId: 1)
->willReturn(89.99);
$result = $this->plugin->afterGetPrice($this->product, 99.99);
$this->assertSame(89.99, $result);
}
#[\PHPUnit\Framework\Attributes\Test]
public function returnsNullWhenOriginalPriceIsNull(): void
{
$this->priceModifier->expects($this->never())->method('applyModifications');
$result = $this->plugin->afterGetPrice($this->product, null);
$this->assertNull($result);
}
}
5. Observer-Struktur für Testbarkeit
Ein Magento-Observer implementiert das Magento\Framework\Event\ObserverInterface und hat eine einzige Methode: execute(Observer $observer): void. Der Observer bekommt ein Observer-Objekt, das das ausgelöste Event enthält. Über $observer->getEvent()->getData('quote') oder spezifischere Methoden wie $observer->getEvent()->getQuote() werden die Event-Daten extrahiert.
Die testbare Struktur: Der Observer extrahiert die Daten aus dem Event und übergibt sie als typisierte Parameter an einen Service. Die gesamte fachliche Logik lebt im Service. Das Observer-Objekt wird im Test gemockt, ebenso das Event-Objekt. Mit PHPUnit lässt sich eine Methodenkette wie $observer->getEvent()->getQuote() durch verschachtelte Mocks simulieren.
<?php
declare(strict_types=1);
namespace Mironsoft\Tests\Unit\Checkout\Observer;
use Magento\Framework\Event;
use Magento\Framework\Event\Observer;
use Magento\Quote\Model\Quote;
use Mironsoft\Checkout\Observer\ApplyCartRulesObserver;
use Mironsoft\Checkout\Service\CartRuleApplierService;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\MockObject\MockObject;
/**
* Unit test for ApplyCartRulesObserver.
*
* Verifies that the observer extracts Quote from Event
* and delegates to CartRuleApplierService.
*/
final class ApplyCartRulesObserverTest extends TestCase
{
private CartRuleApplierService&MockObject $ruleApplier;
private Observer&MockObject $observerMock;
private Event&MockObject $eventMock;
private Quote&MockObject $quoteMock;
protected function setUp(): void
{
$this->ruleApplier = $this->createMock(CartRuleApplierService::class);
$this->observerMock = $this->createMock(Observer::class);
$this->eventMock = $this->createMock(Event::class);
$this->quoteMock = $this->createMock(Quote::class);
$this->observerMock->method('getEvent')->willReturn($this->eventMock);
$this->eventMock->method('getQuote')->willReturn($this->quoteMock);
}
#[\PHPUnit\Framework\Attributes\Test]
public function appliesCartRulesForValidQuote(): void
{
$this->ruleApplier
->expects($this->once())
->method('applyRulesForQuote')
->with($this->quoteMock);
$observer = new ApplyCartRulesObserver($this->ruleApplier);
$observer->execute($this->observerMock);
}
}
6. EventManager und Event-Objekte mocken
Wenn der zu testende Code selbst Events dispatcht (z.B. $this->eventManager->dispatch('mironsoft_price_changed', ['product' => $product])), muss der EventManager gemockt werden. Das Interface Magento\Framework\Event\ManagerInterface hat eine einzige Methode dispatch(string $eventName, array $data = []): void, die leicht gemockt werden kann. Der Test prüft, dass der EventManager mit dem richtigen Event-Namen und den korrekten Daten aufgerufen wird.
Wenn der zu testende Code auf den Rückgabewert des Events angewiesen ist – in Magento ist dispatch() void, aber manchmal werden Daten über referenzierte Objekte im Data-Array zurückgegeben – muss die Mock-Konfiguration dies berücksichtigen. Das gemockte Objekt wird direkt im Data-Array übergeben, sodass der zu testende Code Methoden darauf aufrufen kann und der Mock kontrollierte Werte zurückgibt.
7. Around-Plugins sicher testen
Around-Plugins erhalten als ersten Parameter das Subject, als zweiten das $proceed-Callable. Im Test muss dieses Callable simuliert werden. Das gelingt mit einer einfachen PHP-Closure, die den erwarteten Rückgabewert liefert. So kann geprüft werden, ob das Around-Plugin die Originalmethode aufruft ($proceed wird mit den richtigen Argumenten aufgerufen), ob es das Ergebnis korrekt modifiziert, und ob es in Fehlerfällen die Originalmethode auslässt.
Der Test für ein Around-Plugin, das einen Cache prüft und bei Cache-Treffer die Originalmethode überspringt, übergibt eine Closure als $proceed, die einen Spy-Flag setzt, wenn sie aufgerufen wird. Nach dem Aufruf des Around-Plugins prüft der Test, ob der Spy-Flag gesetzt wurde oder nicht – je nach erwartetem Verhalten. Das ist eleganter als ein vollständiger Mock und deutlich besser lesbar.
8. Integration mit Magento-Integrationstests
Nicht alle Plugin- und Observer-Tests können Unit-Tests sein. Wenn ein Plugin auf das Magento-Datenmodell angewiesen ist und die Korrektheit des Zusammenspiels geprüft werden soll, ist ein Integrationstest der richtige Ansatz. Magento liefert ein eigenes Integrations-Test-Framework unter dev/tests/integration/, das den vollständigen Magento-Bootstrap durchführt und Tests gegen eine echte Testdatenbank ausführt.
In Magento-Integrationstests werden Objekte über den ObjectManager instanziiert ($this->objectManager->create(ProductPricePlugin::class)), was die vollständige DI-Konfiguration berücksichtigt. Das ist langsamer als Unit-Tests (Sekunden statt Millisekunden), aber notwendig, um zu prüfen, ob die di.xml-Konfiguration korrekt ist und das Plugin tatsächlich auf die richtige Methode greift. Unit-Tests für Logik, Integrationstests für Verdrahtung – diese Aufteilung ist in Magento-Projekten besonders wichtig.
9. Teststrategien im Vergleich
Die Wahl der Teststrategie für Plugins und Observer hat direkte Auswirkungen auf Testgeschwindigkeit und Aussagekraft der Tests.
| Teststrategie | Vorteile | Nachteile | Einsatzgebiet |
|---|---|---|---|
| Unit-Test Service | Millisekunden, kein Magento | Kein di.xml-Test | Geschäftslogik isoliert testen |
| Unit-Test Plugin | Delegation und Null-Guard prüfen | Kein Interceptor-Test | Plugin-Kopplung verifizieren |
| Magento Integration | Vollständige DI, echte Daten | Langsam, Datenbank nötig | Verdrahtung und di.xml prüfen |
| Kein Test | Schnell "entwickelt" | Regressionen unentdeckt | Nur für Wegwerfcode akzeptabel |
Mironsoft
Magento-2-Entwicklung, Plugin-Architektur und Test-Strategie
Magento-Plugins testbar machen?
Wir analysieren eure Magento-Plugins und Observer, extrahieren Geschäftslogik in testbare Services und bauen eine Test-Architektur auf, die schnelle Unit-Tests mit gezielten Integrationstests kombiniert.
Plugin-Refactoring
Logik in testbare Services auslagern ohne bestehende Funktion zu brechen
Test-Architektur
Unit-Tests für Services, Integrationstests für DI-Verdrahtung aufbauen
CI-Pipeline
PHPUnit in Magento-CI-Pipeline mit Test-Suiten und Coverage integrieren
10. Zusammenfassung
Magento-Plugins und Observer testbar halten ist keine Frage des Frameworks, sondern des Designs. Wer Geschäftslogik konsequent aus Plugins und Observern in eigenständige Services extrahiert, kann diese Services mit PHPUnit isoliert testen – ohne Magento-Bootstrap, ohne Datenbankverbindung, in Millisekunden. Das Plugin oder der Observer selbst wird dünn und benötigt kaum eigene Tests, weil seine einzige Verantwortung die Delegation ist.
Die Kombination aus schnellen Unit-Tests für Services und gezielten Integrationstests für die DI-Verdrahtung ist die praxistaugliche Teststrategie für Magento-2-Projekte. Sie ermöglicht schnelles Feedback während der Entwicklung und vollständige Absicherung vor dem Deployment. Wer dieses Muster konsequent anwendet, stellt fest, dass Magento-Code sehr wohl testbar ist – es braucht nur das richtige Architektur-Fundament.
Magento Plugins und Observer testbar halten — Das Wichtigste auf einen Blick
Logik extrahieren
Geschäftslogik aus Plugin und Observer in eigenständige Services auslagern. Plugin und Observer delegieren nur – keine eigene Logik.
Services isoliert testen
Services haben keine Abhängigkeit auf ObjectManager oder generierte Klassen. PHPUnit-Unit-Tests laufen ohne Magento-Bootstrap in Millisekunden.
Mocks für Events
Observer-Tests mocken Event und Observer-Objekt. EventManager-Tests mocken ManagerInterface. Verschachtelte Mocks für Methodenketten nutzen.
Integrationstests
DI-Verdrahtung und di.xml-Konfiguration nur über Magento-Integrationstests prüfen. Unit-Tests für Logik, Integrationstests für Verdrahtung.