@test
assert
Magento 2 · PHPUnit · Unit Tests · ViewModel
Magento Unit Tests:
Was ohne Bootstrap wirklich sinnvoll ist

Magento-Unit-Tests ohne Bootstrap laufen in Sekunden statt in Minuten. Aber nicht jede Magento-Klasse lässt sich sinnvoll ohne Framework-Initialisierung testen. ViewModels, reine Service Classes, Plugins auf einfache Daten-Transformationen und Preisberechnungslogik sind ideal. Block-Rendering, Layout-Verarbeitung und Observer-Ketten hingegen brauchen den Bootstrap – und das ist kein Versagen des Tests.

16 Min. Lesezeit ViewModel · Plugin · ServiceClass · Repository · DataObject Magento 2.4.8 · PHP 8.4 · PHPUnit 11

1. Das Bootstrap-Problem in Magento-Tests

Magento 2 hat zwei verschiedene PHPUnit-Konfigurationen: eine für Unit Tests (dev/tests/unit/phpunit.xml) und eine für Integrations- und Funktionstests (dev/tests/integration/phpunit.xml). Der entscheidende Unterschied: Unit Tests verwenden keinen Magento-Bootstrap. Sie laden nur Autoloading und die eigene Testklasse – keine Datenbankverbindung, keine Dependency-Injection-Container-Initialisierung, keine Event-Observer-Registrierung. Das macht Unit Tests extrem schnell: typisch unter 5 Sekunden für eine komplette Suite.

Das führt aber zu einer wichtigen Frage: Welche Magento-Klassen lassen sich ohne Bootstrap sinnvoll testen? Die Antwort hängt davon ab, wie viel der Klasse von Magento-Infrastructure abhängt. Eine Klasse, die nur reine PHP-Objekte verarbeitet und per Constructor-Injection alle Abhängigkeiten erhält, ist ideal für Unit Tests. Eine Klasse, die intern $this->_objectManager->get() aufruft oder auf den Event-Bus zugreift, ist ohne Bootstrap nicht sinnvoll testbar – nicht weil PHPUnit versagt, sondern weil der Test die falsche Werkzeugwahl wäre.

2. Was ohne Bootstrap sinnvoll testbar ist

Die Regel für Magento-Unit-Tests ist einfach: Testbar ohne Bootstrap ist alles, was seine Abhängigkeiten ausschließlich über Constructor-Parameter erhält und keine internen Aufrufe an den Magento-DI-Container, die Datenbankschicht oder das Event-System macht. Das umfasst in einem gut strukturierten Magento-Modul den Großteil der eigenen Geschäftslogik:

ViewModels implementieren ArgumentInterface und erhalten alle Abhängigkeiten per Constructor. Sie transformieren Daten aus Repositories und Konfiguration in Template-taugliche Formate – reine PHP-Logik, perfekt für Unit Tests. Service Classes, die Geschäftslogik implementieren, sind ebenfalls ideal: Preisstaffelungen berechnen, Rabatte anwenden, Lieferdaten schätzen. Plugins (Around, Before, After) auf eigene Klassen, die keine internen Magento-Aufrufe machen, lassen sich vollständig isoliert testen. DataObjects und Value Objects sind die einfachsten Fälle: Setter, Getter und Validierungslogik ohne jede Infrastrukturabhängigkeit.


<?php
// tests/Unit/ViewModel/ProductBadgeViewModelTest.php
declare(strict_types=1);

namespace Tests\Unit\ViewModel;

use PHPUnit\Framework\TestCase;
use Mironsoft\Catalog\ViewModel\ProductBadgeViewModel;
use Mironsoft\Catalog\Model\BadgeConfig;

class ProductBadgeViewModelTest extends TestCase
{
    private ProductBadgeViewModel $viewModel;

    protected function setUp(): void
    {
        $configMock = $this->createMock(BadgeConfig::class);
        $configMock->method('getNewBadgeDays')->willReturn(14);
        $configMock->method('isSaleBadgeEnabled')->willReturn(true);

        $this->viewModel = new ProductBadgeViewModel($configMock);
    }

    public function testIsNewProductWithinConfiguredDays(): void
    {
        $createdAt = new \DateTimeImmutable('-7 days');
        $this->assertTrue($this->viewModel->isNew($createdAt));
    }

    public function testIsNotNewProductBeyondConfiguredDays(): void
    {
        $createdAt = new \DateTimeImmutable('-20 days');
        $this->assertFalse($this->viewModel->isNew($createdAt));
    }

    public function testSaleBadgeLabelContainsCurrency(): void
    {
        $label = $this->viewModel->getSaleBadgeLabel(100.00, 79.95, 'EUR');
        $this->assertStringContainsString('EUR', $label);
        $this->assertStringContainsString('20', $label); // ~20% discount
    }
}

3. ViewModels testen: Datentransformation ohne Layout

ViewModels sind die am einfachsten testbaren Klassen in einem Magento-Modul, weil sie per Design keine Block-Infrastruktur erben. Im Gegensatz zu Block-Klassen haben sie keine _toHtml()-Methode, keinen Template-Engine-Aufruf und keine Layout-Abhängigkeit. Sie implementieren ausschließlich Geschäftslogik für das Template. Das macht sie zu idealen Kandidaten für Unit Tests ohne Bootstrap.

Ein gut strukturierter ViewModel-Unit-Test prüft die Datentransformation vollständig isoliert: Werden Preise korrekt formatiert? Gibt die Methode für einen ausverkauften Artikel den richtigen Stockstatus zurück? Werden Texte abhängig von der Konfiguration korrekt zusammengesetzt? Diese Tests dokumentieren das Verhalten der Template-Hilfsfunktionen und schützen vor Regressionen bei Konfigurationsänderungen. Mit einem DataProvider für verschiedene Preis- und Rabattkombinationen lassen sich mit wenig Code viele Grenzfälle abdecken.


<?php
// tests/Unit/ViewModel/ShippingEstimatorViewModelTest.php
declare(strict_types=1);

namespace Tests\Unit\ViewModel;

use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Mironsoft\Checkout\ViewModel\ShippingEstimatorViewModel;
use Mironsoft\Checkout\Service\ShippingDaysCalculator;

class ShippingEstimatorViewModelTest extends TestCase
{
    #[DataProvider('shippingScenarioProvider')]
    public function testFormatsShippingMessageCorrectly(
        int   $daysUntilDelivery,
        bool  $isExpress,
        string $expectedFragment
    ): void {
        $calculatorMock = $this->createMock(ShippingDaysCalculator::class);
        $calculatorMock->method('calculate')->willReturn($daysUntilDelivery);

        $viewModel = new ShippingEstimatorViewModel($calculatorMock);
        $message   = $viewModel->getEstimatedDeliveryMessage($isExpress);

        $this->assertStringContainsString($expectedFragment, $message);
    }

    public static function shippingScenarioProvider(): array
    {
        return [
            'standard next day'     => [1, false, 'morgen'],
            'standard 3 days'       => [3, false, '3 Werktage'],
            'express same day'      => [0, true,  'heute'],
            'express next day'      => [1, true,  'morgen'],
            'standard long delay'   => [7, false, '7 Werktage'],
        ];
    }

    public function testReturnsEmptyStringWhenCalculationFails(): void
    {
        $calculatorMock = $this->createMock(ShippingDaysCalculator::class);
        $calculatorMock->method('calculate')
            ->willThrowException(new \RuntimeException('Service unavailable'));

        $viewModel = new ShippingEstimatorViewModel($calculatorMock);

        // ViewModel must handle exceptions gracefully — templates cannot catch exceptions
        $this->assertSame('', $viewModel->getEstimatedDeliveryMessage(false));
    }
}

4. Plugins testen: Argument-Manipulation isoliert prüfen

Magento-Plugins (Interceptors) sind ein weiterer Typ, der sich gut ohne Bootstrap testen lässt – vorausgesetzt, das Plugin selbst hat keine internen Abhängigkeiten zur Magento-Infrastruktur. Ein AroundPlugin, das Argumente transformiert oder bestimmte Aufrufe basierend auf Bedingungen überspringt, lässt sich vollständig isoliert testen: Man ruft die aroundMethodName()-Funktion direkt auf und übergibt ein gemocktes Subject und ein gemocktes Callable als Proceed-Closure.

Das Testmuster für Around-Plugins ist dabei immer gleich: Eine Closure wird als $proceed-Argument übergeben, die einen vordefinierten Wert zurückgibt. Im Test prüft man dann, ob das Plugin unter bestimmten Bedingungen die Closure aufruft (normaler Pfad) oder überspringt (Bypass-Pfad), und ob die Argumente vor dem Weiterreichen korrekt modifiziert wurden. Dieses Muster funktioniert ohne jede Kenntnis des eigentlichen Plugin-Subjects – die Geschäftslogik des Plugins steht vollständig im Vordergrund.


<?php
// tests/Unit/Plugin/TaxExemptionPluginTest.php
declare(strict_types=1);

namespace Tests\Unit\Plugin;

use PHPUnit\Framework\TestCase;
use Mironsoft\Tax\Plugin\TaxExemptionPlugin;
use Magento\Tax\Api\TaxCalculationInterface;
use Magento\Tax\Api\Data\QuoteDetailsInterface;

class TaxExemptionPluginTest extends TestCase
{
    private TaxExemptionPlugin $plugin;

    protected function setUp(): void
    {
        $this->plugin = new TaxExemptionPlugin();
    }

    public function testSkipsTaxCalculationForExemptCustomer(): void
    {
        $subject = $this->createMock(TaxCalculationInterface::class);
        $details = $this->createMock(QuoteDetailsInterface::class);

        // Customer extension attributes indicate exemption
        $extensionMock = $this->createMock(\Magento\Tax\Api\Data\QuoteDetailsExtensionInterface::class);
        $extensionMock->method('getIsTaxExempt')->willReturn(true);
        $details->method('getExtensionAttributes')->willReturn($extensionMock);

        $proceedCalled = false;
        $proceed = function () use (&$proceedCalled) {
            $proceedCalled = true;
            return null;
        };

        $result = $this->plugin->aroundCalculateTax($subject, $proceed, $details, 'DE', true);

        $this->assertFalse($proceedCalled, 'Proceed must not be called for exempt customers');
        $this->assertNull($result);
    }

    public function testPassesThroughForNonExemptCustomer(): void
    {
        $subject        = $this->createMock(TaxCalculationInterface::class);
        $details        = $this->createMock(QuoteDetailsInterface::class);
        $extensionMock  = $this->createMock(\Magento\Tax\Api\Data\QuoteDetailsExtensionInterface::class);
        $extensionMock->method('getIsTaxExempt')->willReturn(false);
        $details->method('getExtensionAttributes')->willReturn($extensionMock);

        $expectedResult = $this->createMock(\Magento\Tax\Api\Data\TaxDetailsInterface::class);
        $proceed = fn() => $expectedResult;

        $result = $this->plugin->aroundCalculateTax($subject, $proceed, $details, 'DE', true);

        $this->assertSame($expectedResult, $result);
    }
}

5. Service Classes und Preislogik

Reine Service Classes – Klassen, die ausschließlich Geschäftslogik implementieren und alle Abhängigkeiten per Constructor erhalten – sind die am besten testbaren Komponenten in Magento-Projekten. Eine Klasse, die Staffelpreise berechnet, Mengenrabatte anwendet und Mindestbestellmengen prüft, hat keine Infrastrukturabhängigkeiten und lässt sich mit vollständig gemockten Repositories in Millisekunden testen.

Wichtig ist dabei die Unterscheidung zwischen Preisberechnung (reine Logik, ideal für Unit Tests) und Preisrendering (Template-Engine + Locale + Currency-Formatierung, benötigt teilweise Framework). Die Berechnung, ob ein Rabatt gilt und wie hoch er ist, gehört in eine testbare Service Class. Die Formatierung des Preises in € 49,95 gehört in einen Hyvä-ViewModel, der seinerseits testbar ist, aber die Formatierungs-Engine als Mock erhält.

6. Wo Unit Tests an ihre Grenzen stoßen

Es gibt Magento-Klassen, die ohne Bootstrap nicht sinnvoll testbar sind – und es ist wichtig, diese Grenzen zu kennen, damit man keine Zeit mit fehleranfälligen Test-Setups verschwendet. Block-Klassen, die von AbstractBlock erben, haben in ihrer Vererbungskette Abhängigkeiten zu Context-Objekten, die wiederum den DI-Container voraussetzen. Observers, die über das Event-System ausgelöst werden, sind ohne vollständige Magento-Event-Infrastruktur nicht sinnvoll zu testen.

Repository-Implementierungen gehören ebenfalls in die Integrationstest-Kategorie: Sie sprechen direkt mit dem Resource Model, das eine Datenbankverbindung braucht. Das korrekte Vorgehen ist, das Repository-Interface zu mocken und die Implementierung mit Integrationstests abzudecken. Das ist kein Kompromiss, sondern die richtige Werkzeugwahl: Repository-Implementierungen testen die Interaktion mit MySQL, nicht die Geschäftslogik – und für MySQL-Tests ist ein Bootstrap notwendig.

7. Unit vs. Integration: Entscheidungsmatrix

Die Entscheidung, welcher Testtyp für welche Magento-Komponente angemessen ist, folgt einem einfachen Prinzip: Hat die Klasse Framework-Abhängigkeiten, die nicht durch Mocks ersetzt werden können, braucht sie einen Integrationstest.

Magento-Komponente Unit Test Integrationstest Begründung
ViewModel Ja Nicht nötig Reine Logik, keine Block-Infrastruktur
Service Class Ja Optional für DB-nahe Services Mockbare Abhängigkeiten per Constructor
Plugin (einfach) Ja Optional Proceed-Closure testbar simulierbar
Repository-Interface Mock in Unit Test Implementierung Interface mocken, Impl. mit DB testen
Block (AbstractBlock) Nicht sinnvoll Ja Context erfordert DI-Container
Observer Nur Logik-Extraktion Ja Event-System braucht Bootstrap

Die Kernaussage der Tabelle: Unit Tests sind für alle Komponenten sinnvoll, die ihre Abhängigkeiten per Constructor erhalten und keine internen Magento-Framework-Aufrufe machen. Die ViewModel-Architektur in Magento 2 (und besonders in Hyvä) ist explizit so designed, dass ViewModels diese Eigenschaft haben. Wer konsequent auf ViewModels statt Block-Klassen setzt, schafft automatisch eine bessere testbare Codebasis.

8. Zusammenfassung

Magento-Unit-Tests ohne Bootstrap sind kein Kompromiss, sondern die richtige Wahl für alle Klassen, die keine Framework-Infrastructure benötigen. ViewModels, Service Classes, einfache Plugins und DataObjects lassen sich vollständig isoliert testen – schnell, deterministisch und ohne Datenbankverbindung. Die Laufzeit einer gut strukturierten Unit-Test-Suite für ein Magento-Modul liegt unter 5 Sekunden, was sie ideal für die CI-Pipeline bei jedem Commit macht.

Die Entscheidung für oder gegen Unit Tests ist keine Frage der Testgüte, sondern der Werkzeugwahl. Repository-Implementierungen, Blocks und Observer-Event-Handling gehören in Integrationstests – nicht weil Unit Tests schwieriger wären, sondern weil die Infrastrukturabhängigkeiten durch Mocks nur unvollständig ersetzt werden können. Ein klares mentales Modell, welche Klassen wo getestet werden, ist effizienter als der Versuch, alles in Unit Tests zu pressen oder alles in langsamen Integrationstests.

Magento Unit Tests ohne Bootstrap — Das Wichtigste auf einen Blick

ViewModel = ideal für Unit Tests

Keine Block-Infrastruktur, reine Logik, mockbare Abhängigkeiten. DataProvider-Tests decken alle Eingabe-Kombinationen ab.

Plugins: Proceed-Closure simulieren

AroundPlugin direkt aufrufen, Closure als Proceed übergeben. Prüfen ob Proceed aufgerufen wird und ob Argumente korrekt modifiziert wurden.

Grenzen kennen

Block (AbstractBlock), Repository-Implementierung und Event-Observer brauchen Integrationstests mit Bootstrap – das ist korrekte Werkzeugwahl.

Suite-Laufzeit

Unter 5 Sekunden bei richtiger Abgrenzung. Unit Tests im CI bei jedem Commit, Integrationstests auf eigenen Runs oder nightly.

9. FAQ: Magento Unit Tests ohne Bootstrap

1Unit Test vs. Integrationstest in Magento?
Unit Tests: kein Bootstrap, Sekunden-Laufzeit. Integrationstests: voller Magento-Stack mit DB, Minuten-Laufzeit. Unit Tests für Logik, Integrationstests für Zusammenspiel.
2Warum sind ViewModels besser testbar als Blocks?
Keine Context-Vererbung, kein DI-Container-Bedarf. Alle Abhängigkeiten per Constructor – vollständig mockbar in Unit Tests ohne Bootstrap.
3DataObjects in Unit Tests verwenden?
Einfache DataObject-Subklassen ohne internen ObjectManager-Zugriff funktionieren. Bei DataObjectFactory intern: Mock der Factory oder Integrationstest nutzen.
4Around-Plugin in Unit Tests testen?
aroundMethodName() direkt aufrufen mit gemocktem Subject und einer Closure als $proceed. Flag-Variable prüft ob Proceed aufgerufen wurde.
5Welche Klassen brauchen Integrationstests?
Repository-Implementierungen, Block-Klassen, Observer-Verhalten im Event-Kontext, Setup-Patches. Alles, das DB oder DI-Container benötigt.
6PHPUnit für Magento Unit Tests einrichten?
dev/tests/unit/phpunit.xml verwenden oder eigene Konfiguration im Modul. Autoloading über composer.json. Kein Bootstrap nötig.
7Magento-Core-Klassen mocken oder echt instanziieren?
Interfaces immer mocken. Einfache DataObjects ohne interne Framework-Aufrufe können echt instanziiert werden. ProductInterface immer mocken.
8Konfigurationswerte in Unit Tests testen?
ScopeConfigInterface mocken: method('getValue')->with('path/to/config')->willReturn('value'). DataProvider für verschiedene Konfigurationszustände.
9Fixtures für Produktdaten in Unit Tests?
In Unit Tests Produktdaten über gemockte Repositories liefern. DB-Fixtures sind Integrationstests-Werkzeuge. Mocks sind deterministischer und schneller.
10Wie oft Unit Tests in CI laufen lassen?
Bei jedem Commit und PR. Unter 5 Sekunden Laufzeit – kein Grund zur Einschränkung. Integrationstests auf separaten Runs oder nightly.