Plugin-Reihenfolge und sortOrder mit Tests absichern
AI generated
@test
assert
PHPUnit · Magento · Plugins
Plugin-Reihenfolge und sortOrder
mit PHPUnit-Tests absichern

Mehrere Plugins auf derselben Methode haengen still von der sortOrder in di.xml ab. Ein Test, der die tatsaechliche Ausfuehrungsreihenfolge dokumentiert, verhindert, dass ein neues Plugin unbemerkt eine bestehende Reihenfolge und damit das Verhalten kippt.

14 Min. Lesezeit di.xml sortOrder Interceptor Plugin-Ketten

1. Warum Plugin-Reihenfolge ein unsichtbares, aber kritisches Verhalten ist

Magentos Plugin-System erlaubt es, dass mehrere Module dieselbe Methode derselben Klasse abfangen, gesteuert ausschliesslich durch die sortOrder in den jeweiligen di.xml-Dateien. Diese Reihenfolge ist an keiner zentralen Stelle sichtbar dokumentiert, sondern ergibt sich implizit aus der Summe aller Module, die auf dieselbe Methode zugreifen. Fuer 'before'-Plugins bestimmt eine niedrigere sortOrder eine frühere Ausfuehrung, fuer 'after'-Plugins kehrt sich die Wirkung auf das Endergebnis faktisch um, da spaeter ausgefuehrte 'after'-Plugins den Rueckgabewert der vorherigen ueberschreiben koennen.

Das Risiko wird konkret, sobald ein zweites Team oder ein drittes Modul ein weiteres Plugin auf dieselbe Methode registriert. Ohne bewusste Abstimmung landet die sortOrder zufaellig irgendwo, und ein Plugin, das eigentlich vor der Preisberechnung laufen sollte, laeuft ploetzlich danach. Das Ergebnis ist ein Bug, der sich nicht durch fehlerhaften Code in einem einzelnen Plugin erklaeren laesst, sondern nur durch das Zusammenspiel mehrerer Plugins in der falschen Reihenfolge, ein Fehlerbild, das besonders muehsam zu debuggen ist.

2. Ein Beispiel-Szenario mit drei konkurrierenden Plugins

Zur Veranschaulichung dient ein Preis-Berechnungsdienst mit drei Plugins: ein Rabatt-Plugin, das einen prozentualen Rabatt abzieht, ein Steuer-Plugin, das die Mehrwertsteuer aufschlaegt, und ein Rundungs-Plugin, das den finalen Preis auf zwei Nachkommastellen rundet. Die fachlich korrekte Reihenfolge ist eindeutig: erst Rabatt, dann Steuer, zuletzt Rundung. Wuerde die Rundung vor der Steuerberechnung laufen, entstuenden durch doppeltes Runden minimale, aber reale Centabweichungen im finalen Preis.

In der di.xml wird diese Reihenfolge ueber drei sortOrder-Werte festgelegt, zum Beispiel 10 fuer den Rabatt, 20 fuer die Steuer und 30 fuer die Rundung. Diese Zahlen allein sagen einem neuen Entwickler aber nichts ueber die fachliche Notwendigkeit dieser Reihenfolge, sie sehen wie eine willkuerliche Nummerierung aus. Genau hier setzt ein Test an, der die tatsaechliche Ausfuehrungsreihenfolge explizit macht und damit auch die fachliche Begruendung dokumentiert.


<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <type name="Mironsoft\Pricing\Model\PriceCalculator">
        <plugin name="mironsoft_discount" type="Mironsoft\Pricing\Plugin\ApplyDiscountPlugin" sortOrder="10" />
        <plugin name="mironsoft_tax" type="Mironsoft\Pricing\Plugin\ApplyTaxPlugin" sortOrder="20" />
        <plugin name="mironsoft_rounding" type="Mironsoft\Pricing\Plugin\RoundPricePlugin" sortOrder="30" />
    </type>
</config>

3. Die tatsaechliche Ausfuehrungsreihenfolge mit einem Integrationstest pruefen

Da Plugin-Verdrahtung ein Interceptor-Mechanismus ist, der von Magentos generiertem Code getragen wird, laesst sich die reale Reihenfolge nur zuverlaessig ueber einen Integrationstest pruefen, der die tatsaechliche, durch den Object Manager erzeugte Klasse aufruft. Ein Unit-Test mit direkt instanziierter Klasse wuerde die Plugins komplett umgehen und damit genau das nicht testen, worauf es ankommt.

Der Trick fuer einen aussagekraeftigen Reihenfolge-Test ist ein Logging-Mechanismus: Jedes Plugin traegt beim Durchlaufen seinen eigenen Namen in ein gemeinsames Protokoll ein, zum Beispiel ueber ein injiziertes, im Test austauschbares Logger-Objekt. Nach dem Aufruf der Methode prueft der Test, ob die Namen exakt in der erwarteten Reihenfolge im Protokoll stehen. Das macht die Ausfuehrungsreihenfolge zu einer expliziten, versionierten Testaussage statt einer stillen Annahme in drei separaten di.xml-Dateien.


<?php
declare(strict_types=1);

namespace Mironsoft\Pricing\Test\Integration\Model;

use Magento\TestFramework\Helper\Bootstrap;
use Mironsoft\Pricing\Model\PriceCalculator;
use Mironsoft\Pricing\Test\Fixture\PluginExecutionRecorder;
use PHPUnit\Framework\TestCase;

class PriceCalculatorPluginOrderTest extends TestCase
{
    public function testPluginsRunInDiscountTaxRoundingOrder(): void
    {
        $objectManager = Bootstrap::getObjectManager();
        PluginExecutionRecorder::reset();

        /** @var PriceCalculator $calculator */
        $calculator = $objectManager->create(PriceCalculator::class);
        $calculator->calculate(100.0);

        self::assertSame(
            ['mironsoft_discount', 'mironsoft_tax', 'mironsoft_rounding'],
            PluginExecutionRecorder::getExecutionOrder()
        );
    }
}

4. Einen einfachen Execution-Recorder als wiederverwendbaren Testbaustein bauen

Der Execution-Recorder aus dem vorherigen Beispiel ist bewusst simpel gehalten: eine statische Klasse mit einem Array, in das jedes teilnehmende Plugin seinen Namen eintraegt, sobald es durchlaufen wird. Statische Zustaende sind in Unit-Tests grundsaetzlich mit Vorsicht zu geniessen, aber fuer diesen sehr eng begrenzten Zweck, eine Reihenfolge innerhalb eines einzelnen Testlaufs zu protokollieren, sind sie ein pragmatisches und leicht verstaendliches Werkzeug.

Wichtig ist, den Recorder vor jedem Test explizit zurueckzusetzen, damit Ergebnisse aus vorherigen Tests nicht faelschlich in den aktuellen Test hineinwirken. Jedes Plugin ruft den Recorder in seiner beforeCalculate() oder afterCalculate()-Methode auf, was in Produktion keinerlei Overhead verursacht, wenn der Recorder-Aufruf nur im Testkontext oder ueber ein Null-Object-Pattern aktiviert wird.


<?php
declare(strict_types=1);

namespace Mironsoft\Pricing\Test\Fixture;

class PluginExecutionRecorder
{
    /** @var string[] */
    private static array $order = [];

    public static function reset(): void
    {
        self::$order = [];
    }

    public static function record(string $pluginName): void
    {
        self::$order[] = $pluginName;
    }

    /**
     * @return string[]
     */
    public static function getExecutionOrder(): array
    {
        return self::$order;
    }
}

5. Alternativ: Reihenfolge indirekt ueber das Endergebnis pruefen

Nicht jedes Team moechte einen dedizierten Recorder in Produktionscode einbauen, selbst wenn er nur im Testkontext aktiv ist. Eine Alternative ist, die Reihenfolge indirekt ueber das fachlich korrekte Endergebnis zu pruefen: Bei einem Preis von hundert Euro, zehn Prozent Rabatt und neunzehn Prozent Steuer ergibt die korrekte Reihenfolge (erst Rabatt, dann Steuer) ein anderes Endergebnis als die falsche Reihenfolge (erst Steuer, dann Rabatt).

Dieser Ansatz ist weniger explizit als der Execution-Recorder, weil ein Testversagen nicht sofort zeigt, welche zwei Plugins vertauscht wurden, sondern nur, dass irgendetwas an der Berechnung nicht stimmt. Er hat aber den Vorteil, komplett ohne Testinfrastruktur im Produktionscode auszukommen und pruft gleichzeitig, ob die Berechnung insgesamt fachlich korrekt ist, nicht nur, ob die Reihenfolge formal stimmt.


<?php
declare(strict_types=1);

namespace Mironsoft\Pricing\Test\Integration\Model;

use Magento\TestFramework\Helper\Bootstrap;
use Mironsoft\Pricing\Model\PriceCalculator;
use PHPUnit\Framework\TestCase;

/**
 * @magentoConfigFixture default_store mironsoft_pricing/discount/percent 10
 * @magentoConfigFixture default_store tax/calculation/rate 19
 */
class PriceCalculatorResultOrderTest extends TestCase
{
    public function testDiscountAppliedBeforeTaxProducesExpectedTotal(): void
    {
        $objectManager = Bootstrap::getObjectManager();
        /** @var PriceCalculator $calculator */
        $calculator = $objectManager->create(PriceCalculator::class);

        $result = $calculator->calculate(100.0);

        // 100 - 10% discount = 90.00, then + 19% tax = 107.10
        self::assertSame(107.10, $result);
    }
}

6. Regressionsschutz beim Hinzufuegen eines vierten Plugins

Der eigentliche Wert eines Reihenfolge-Tests zeigt sich, sobald ein weiteres Team ein viertes Plugin auf dieselbe Methode registriert, etwa ein Treuepunkte-Plugin, das zusaetzliche Punkte basierend auf dem finalen Preis berechnet. Ohne Reihenfolge-Test faellt eine falsch gewaehlte sortOrder, die das Treuepunkte-Plugin versehentlich vor die Rundung setzt, moeglicherweise erst in der Produktion auf, wenn Punkteberechnungen minimal von den ungerundeten statt den gerundeten Preisen abweichen.

Mit einem bestehenden Reihenfolge-Test schlaegt der Test sofort fehl, sobald das neue Plugin registriert wird, weil die erwartete Reihenfolge im Assert nicht mehr mit der tatsaechlichen uebereinstimmt. Der Entwickler des neuen Plugins wird damit gezwungen, die sortOrder bewusst zu waehlen und den Test explizit zu aktualisieren, statt sich zufaellig irgendwo in die Kette einzureihen. Der Test wird so zu einem aktiven Kommunikationskanal zwischen Teams, die sonst nichts voneinander wissen.


<?php
declare(strict_types=1);

namespace Mironsoft\Pricing\Test\Integration\Model;

use Magento\TestFramework\Helper\Bootstrap;
use Mironsoft\Pricing\Model\PriceCalculator;
use Mironsoft\Pricing\Test\Fixture\PluginExecutionRecorder;
use PHPUnit\Framework\TestCase;

class PriceCalculatorPluginOrderWithLoyaltyTest extends TestCase
{
    public function testLoyaltyPluginRunsAfterRounding(): void
    {
        $objectManager = Bootstrap::getObjectManager();
        PluginExecutionRecorder::reset();

        /** @var PriceCalculator $calculator */
        $calculator = $objectManager->create(PriceCalculator::class);
        $calculator->calculate(100.0);

        self::assertSame(
            ['mironsoft_discount', 'mironsoft_tax', 'mironsoft_rounding', 'mironsoft_loyalty_points'],
            PluginExecutionRecorder::getExecutionOrder()
        );
    }
}

7. Besonderheiten bei gemischten before- und after-Plugins beruecksichtigen

Die Reihenfolge wird komplexer, sobald sowohl 'before'- als auch 'after'-Plugins auf derselben Methode aktiv sind, da Magento fuer 'before'-Plugins die sortOrder aufsteigend, aber fuer 'after'-Plugins de facto in der Reihenfolge anwendet, in der sie den Rueckgabewert weiterreichen, was bei niedrigerer sortOrder zuerst den inneren, bei hoeherer sortOrder den aeusseren Wrapper bildet. Ein Test, der nur 'around'-Plugins betrachtet, deckt dieses Verhalten nicht ab.

Fuer gemischte Szenarien lohnt es sich, im Recorder explizit zu protokollieren, ob ein Eintrag aus einer 'before'- oder 'after'-Methode stammt, etwa als 'mironsoft_discount:before' und 'mironsoft_discount:after'. So macht der Test sichtbar, in welcher Reihenfolge sowohl der Eintritt als auch der Austritt aus jedem Plugin erfolgt, was insbesondere bei Plugins wichtig ist, die den Rueckgabewert nachtraeglich veraendern.

8. Den Test gleichzeitig als lesbare Dokumentation der Reihenfolge nutzen

Ueber die reine Regressionssicherung hinaus hat ein Reihenfolge-Test einen zweiten Wert: Er ist die einzige Stelle im Code, an der die fachliche Notwendigkeit einer bestimmten Plugin-Reihenfolge explizit als Kommentar und als Assertion nebeneinander stehen. Waehrend die sortOrder-Werte in den verschiedenen di.xml-Dateien fuer sich genommen keine Begruendung tragen, kann der Test-Docblock genau erklaeren, warum Rabatt vor Steuer und Steuer vor Rundung laufen muss.

Diese Doppelrolle macht den Test zu einem wertvollen Onboarding-Werkzeug fuer neue Teammitglieder: Statt drei verschiedene di.xml-Dateien in drei verschiedenen Modulen zu durchsuchen, um die Gesamtlogik zu verstehen, liefert ein Blick in die Testdatei sofort die komplette Kette samt Begruendung. Diese Eigenschaft rechtfertigt den Aufwand fuer den Reihenfolge-Test auch dann, wenn ein Team aktuell keine akuten Reihenfolge-Bugs hat.

9. Plugin-Reihenfolge-Tests strategisch in die Test-Suite einordnen

Da Plugin-Reihenfolge-Tests zwingend Integrationstests sind, sie brauchen den echten, generierten Interceptor, sind sie langsamer als reine Unit-Tests, aber immer noch deutlich schneller als ein kompletter End-to-End-Test ueber die REST-API. Sie gehoeren in die Integrationstest-Phase der CI-Pipeline und sollten bei jeder Aenderung an einer di.xml-Datei mit betroffenen Plugins ausgefuehrt werden.

Ein pragmatischer Ansatz ist, Reihenfolge-Tests gezielt fuer alle Methoden anzulegen, auf denen drei oder mehr Plugins aus unterschiedlichen Modulen aktiv sind, da hier das Risiko einer unbeabsichtigten sortOrder-Kollision am hoechsten ist. Fuer Methoden mit nur einem einzigen Plugin ist ein solcher Test unnoetiger Aufwand, da es dort per Definition keine Reihenfolge zu testen gibt.

Testansatz Was er zeigt Vorteil Nachteil
Execution-Recorder Exakte Reihenfolge aller Plugin-Aufrufe Sofort erkennbar, welche zwei Plugins vertauscht sind Braucht zusaetzlichen Test-Baustein im Code
Ergebnis-basierter Test Fachlich korrektes Endergebnis der Berechnung Keine Testinfrastruktur im Produktionscode noetig Fehlerursache bei Fehlschlag weniger konkret
before/after getrennt protokolliert Ein- und Austrittsreihenfolge bei gemischten Plugin-Typen Deckt auch around- und after-Wrapper-Verhalten ab Etwas aufwendiger im Recorder-Setup
Data-Provider mit mehreren Startwerten Reihenfolge-Korrektheit ueber mehrere Eingaben hinweg Deckt Rundungs- und Grenzfaelle zusaetzlich ab Mehr Testfaelle zu pflegen

Mironsoft

Testautomatisierung, Magento-Qualitätssicherung und CI-Integration

Tests, die echte Fehler finden statt nur grün zu leuchten?

Wir prüfen bestehende PHPUnit-Suiten auf Implementierungsdetail-Tests, flaky Tests und fehlende Coverage an kritischen Stellen und bauen daraus eine Teststrategie, die bei jedem Magento-Update wirklich Sicherheit gibt.

Test-Audit

Bestehende Suiten auf Mocking-Antipatterns und blinde Flecken prüfen.

Teststrategie

Unit-, Integrations- und MFTF-Tests sinnvoll für Magento-Projekte kombinieren.

CI-Integration

Schnelle, zuverlässige Testläufe in GitLab CI oder GitHub Actions einrichten.

10. Zusammenfassung

Plugin-Reihenfolge-Tests: Das Wichtigste auf einen Blick

Kernidee

Tatsaechliche Plugin-Ausfuehrungsreihenfolge ueber einen Integrationstest mit Execution-Recorder explizit machen.

Warum Unit-Test nicht reicht

Plugins wirken nur ueber den generierten Interceptor, den ein direkt instanziiertes Objekt umgeht.

Zusatznutzen

Der Test dokumentiert gleichzeitig die fachliche Begruendung der Reihenfolge fuer neue Teammitglieder.

Wann besonders wichtig

Bei drei oder mehr Plugins aus unterschiedlichen Modulen auf derselben Methode.

11. FAQ: Plugin-Reihenfolge-Tests: Das Wichtigste auf einen Blick

1Warum reicht ein Unit-Test nicht aus, um Plugin-Reihenfolge zu pruefen?
Weil Plugins nur ueber den von Magento generierten Interceptor wirken. Ein Unit-Test mit direkt instanziierter Klasse umgeht diesen Mechanismus vollstaendig und testet damit die Plugins gar nicht.
2Wie protokolliere ich die tatsaechliche Ausfuehrungsreihenfolge mehrerer Plugins?
Ueber einen einfachen Execution-Recorder, den jedes Plugin beim Durchlaufen mit seinem eigenen Namen aufruft. Der Test prueft danach, ob die protokollierte Reihenfolge der erwarteten entspricht.
3Was passiert, wenn ein neues Plugin die sortOrder-Reihenfolge unbeabsichtigt aendert?
Ohne Test faellt das oft erst in Produktion auf, etwa durch minimale Preisabweichungen. Mit einem Reihenfolge-Test schlaegt der Test sofort fehl, sobald das neue Plugin registriert wird.
4Wie unterscheidet sich die Reihenfolge bei before- und after-Plugins?
Bei before-Plugins bestimmt eine niedrigere sortOrder eine fruehere Ausfuehrung. Bei after-Plugins wirkt sich eine hoehere sortOrder auf den aeusseren Wrapper aus, was das Verhalten gegenueber before-Plugins faktisch umkehrt.
5Ist ein ergebnis-basierter Test eine gute Alternative zum Execution-Recorder?
Ja, wenn keine zusaetzliche Testinfrastruktur im Produktionscode gewuenscht ist. Er prueft indirekt ueber das fachlich korrekte Endergebnis, zeigt bei einem Fehlschlag aber nicht direkt, welche zwei Plugins vertauscht wurden.
6Sollte ich fuer jede Methode mit nur einem Plugin einen Reihenfolge-Test schreiben?
Nein, bei nur einem Plugin gibt es per Definition keine Reihenfolge zu testen. Reihenfolge-Tests lohnen sich gezielt bei drei oder mehr Plugins aus unterschiedlichen Modulen.
7Wie gehe ich mit gemischten before- und after-Plugins in einem Reihenfolge-Test um?
Der Recorder sollte explizit protokollieren, ob ein Eintrag aus der before- oder der after-Methode stammt, um sowohl Ein- als auch Austrittsreihenfolge sichtbar zu machen.
8Gehoeren Plugin-Reihenfolge-Tests in die schnelle Unit-Test-Phase der CI-Pipeline?
Nein, da sie den echten generierten Interceptor brauchen, sind es zwingend Integrationstests. Sie gehoeren in die Integrationstest-Phase, sind aber weiterhin deutlich schneller als ein vollstaendiger End-to-End-Test.
9Welchen Nutzen hat ein Reihenfolge-Test ueber die reine Fehlervermeidung hinaus?
Er dokumentiert die fachliche Begruendung der Reihenfolge an einer zentralen Stelle und dient neuen Teammitgliedern als schneller Ueberblick ueber das Zusammenspiel mehrerer Module.
10Muss ich den Execution-Recorder vor jedem Test zuruecksetzen?
Ja, sonst koennen Ergebnisse aus vorherigen Tests faelschlich in den aktuellen Test hineinwirken, besonders bei statischen Zustaenden, die ueber Testfaelle hinweg bestehen bleiben.