Unit Tests für die Kernschichten von Magento 2
Wer Magento-Code ohne Tests schreibt, merkt bei jedem Refactoring, wie fragil die Abhängigkeiten wirklich sind. PHPUnit-Tests für Repositories, Service-Contracts und ViewModels decken die Logik der wichtigsten Schichten ab – ohne den vollständigen Magento-Bootstrap zu benötigen.
Inhaltsverzeichnis
- 1. Warum Repositories, Services und ViewModels testen?
- 2. Testaufbau ohne Magento-Bootstrap
- 3. Repositories mit PHPUnit testen
- 4. Service-Contracts isoliert prüfen
- 5. ViewModels: Logik ohne Block-Overhead
- 6. Mocking-Strategie für Magento-Abhängigkeiten
- 7. Constructor Property Promotion und Dependency Injection
- 8. Typische Fallstricke und wie man sie vermeidet
- 9. Vergleich: Was Unit-Tests leisten, was nicht
- 10. Zusammenfassung
- 11. FAQ
1. Warum Repositories, Services und ViewModels testen?
Magento 2 folgt einem klaren Schichtenmodell: Repositories kapseln Datenzugriff, Service-Contracts definieren die öffentliche API eines Moduls, ViewModels bereiten Daten für Templates auf. Wer diese drei Schichten mit PHPUnit abdeckt, sichert die gesamte Geschäftslogik ab, ohne auf den vollständigen Magento-Bootstrap angewiesen zu sein. Das Ergebnis sind schnelle, deterministisch ablaufende Tests, die in jeder CI-Pipeline ohne spezielle Datenbankverbindung ausführbar sind.
Der entscheidende Vorteil gegenüber manuellen Tests oder Browser-Tests liegt in der Wiederholbarkeit. Eine Änderung an einem Repository-Filter zeigt sofort, ob das erwartete Verhalten erhalten bleibt, sobald der Test läuft. Gerade in Magento-Projekten mit wachsendem Modul-Stack, wo viele Abhängigkeiten ineinandergreifen, ist diese Art von Regressionssicherung unverzichtbar. Der Test deckt nicht die Datenbankinteraktion ab – das ist die Aufgabe von Integrationstests – sondern die Logik, die das Repository mit den übergebenen Parametern aufbaut.
In PHP 8.4 mit Constructor Property Promotion und Strict Types ist der Code ohnehin präziser strukturiert. PHPUnit 10 nutzt Attribute statt Annotationen, was die Tests selbst lesbarer macht. Beide Entwicklungen zusammen machen das Testen von Repositories, Services und ViewModels in modernen Magento-Projekten einfacher als noch in Magento 2.3.
2. Testaufbau ohne Magento-Bootstrap
Der erste Schritt für Unit-Tests in Magento 2 ist das Verständnis, dass dev/tests/unit/ eine eigene phpunit.xml mitbringt, die keinen vollständigen Magento-Bootstrap startet. Stattdessen werden nur die relevanten Klassen des zu testenden Moduls geladen, während alle Abhängigkeiten als Mocks übergeben werden. Das Testverzeichnis im Custom-Modul liegt unter app/code/Vendor/Module/Test/Unit/ und folgt der PSR-4-Namespacing-Konvention des Moduls.
Die phpunit.xml im Projektroot verweist auf den Bootstrap dev/tests/unit/framework/bootstrap.php, der den Autoloader von Magento einbindet, ohne die gesamte Applikation zu initialisieren. Das bedeutet: keine Datenbankverbindung, kein Session-Handling, kein Event-System – nur reines PHP mit den Klassen des Moduls und den Mock-Objekten, die PHPUnit bereitstellt. So läuft eine typische Unit-Test-Suite mit hundert Tests in unter drei Sekunden.
<?php
// File: app/code/Mironsoft/Catalog/Test/Unit/Model/ProductServiceTest.php
declare(strict_types=1);
namespace Mironsoft\Catalog\Test\Unit\Model;
use Mironsoft\Catalog\Api\Data\ProductDtoInterface;
use Mironsoft\Catalog\Model\ProductService;
use Mironsoft\Catalog\Api\ProductRepositoryInterface;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\CoversClass;
#[CoversClass(ProductService::class)]
class ProductServiceTest extends TestCase
{
private ProductRepositoryInterface&MockObject $repositoryMock;
private ProductService $service;
protected function setUp(): void
{
$this->repositoryMock = $this->createMock(ProductRepositoryInterface::class);
$this->service = new ProductService(
repository: $this->repositoryMock,
);
}
#[Test]
public function getActiveReturnsOnlyEnabledProducts(): void
{
$dto = $this->createMock(ProductDtoInterface::class);
$dto->method('isEnabled')->willReturn(true);
$this->repositoryMock
->expects($this->once())
->method('getList')
->willReturn([$dto]);
$result = $this->service->getActive();
$this->assertCount(1, $result);
$this->assertTrue($result[0]->isEnabled());
}
}
3. Repositories mit PHPUnit testen
Ein Magento-Repository implementiert ein Interface aus dem Service-Contract-Layer, zum Beispiel ProductRepositoryInterface. Im Unit-Test geht es nicht darum, die Datenbankabfrage zu prüfen – das ist die Domäne von Integrationstests. Stattdessen testet man, ob das Repository die richtigen Methoden des ResourceModel aufruft, ob Exceptions korrekt transformiert werden und ob Filterlogik die übergebenen Suchkriterien korrekt in ein SearchCriteria-Objekt überführt.
Das Mock des SearchCriteriaBuilder ist dabei besonders wichtig, weil dieser Builder via Fluent-Interface arbeitet und jede Methode $this zurückgibt. PHPUnit 10 unterstützt Chaining-Mocks über willReturnSelf(), was genau dieses Muster abdeckt. So kann man testen, ob addFilter('status', 1) genau einmal aufgerufen wird, bevor create() das finale Objekt liefert.
4. Service-Contracts isoliert prüfen
Service-Contracts in Magento 2 sind Interfaces im Api/-Verzeichnis, die die öffentliche API des Moduls definieren. Eine konkrete Implementierung dieser Interfaces enthält die eigentliche Geschäftslogik und ist der primäre Kandidat für Unit-Tests. Ein typischer Service koordiniert mehrere Repositories, validiert Eingabedaten und wirft definierte Exceptions, die im Interface deklariert sind.
Im Unit-Test mockt man alle Repositories und prüft das Verhalten des Service bei verschiedenen Eingaben: valid data, empty result, exception from repository. Der Fokus liegt auf den Verzweigungen in der Logik – was passiert, wenn das Repository NoSuchEntityException wirft? Wird sie korrekt weitergeleitet oder in eine modulspezifische Exception transformiert? Diese Fallunterscheidungen sind genau das, was PHPUnit am besten abdeckt.
<?php
// File: app/code/Mironsoft/Catalog/Test/Unit/Model/ProductRepositoryTest.php
declare(strict_types=1);
namespace Mironsoft\Catalog\Test\Unit\Model;
use Magento\Framework\Api\SearchCriteriaBuilder;
use Magento\Framework\Api\SearchCriteriaInterface;
use Magento\Framework\Exception\NoSuchEntityException;
use Mironsoft\Catalog\Model\ProductRepository;
use Mironsoft\Catalog\Model\ResourceModel\Product as ProductResource;
use Mironsoft\Catalog\Model\ProductFactory;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\DataProvider;
class ProductRepositoryTest extends TestCase
{
private ProductResource $resourceMock;
private ProductFactory $factoryMock;
private SearchCriteriaBuilder $criteriaBuilderMock;
private ProductRepository $repository;
protected function setUp(): void
{
$this->resourceMock = $this->createMock(ProductResource::class);
$this->factoryMock = $this->createMock(ProductFactory::class);
$this->criteriaBuilderMock = $this->createMock(SearchCriteriaBuilder::class);
$this->repository = new ProductRepository(
resource: $this->resourceMock,
productFactory: $this->factoryMock,
criteriaBuilder: $this->criteriaBuilderMock,
);
}
#[Test]
public function getByIdThrowsNoSuchEntityExceptionForMissingProduct(): void
{
$product = $this->createMock(\Mironsoft\Catalog\Model\Product::class);
$product->method('getId')->willReturn(null);
$this->factoryMock->method('create')->willReturn($product);
$this->resourceMock->method('load')->willReturnArgument(0);
$this->expectException(NoSuchEntityException::class);
$this->repository->getById(9999);
}
#[Test]
#[DataProvider('validIdProvider')]
public function getByIdReturnsProductForValidId(int $id): void
{
$product = $this->createMock(\Mironsoft\Catalog\Model\Product::class);
$product->method('getId')->willReturn($id);
$this->factoryMock->method('create')->willReturn($product);
$this->resourceMock->method('load')->willReturnArgument(0);
$result = $this->repository->getById($id);
$this->assertSame($id, $result->getId());
}
public static function validIdProvider(): array
{
return [[1], [42], [100]];
}
}
5. ViewModels: Logik ohne Block-Overhead
ViewModels in Magento 2 implementieren ArgumentInterface und enthalten die Logik, die früher in Block-Klassen lebte. Sie sind für Unit-Tests besonders gut geeignet, weil sie keine Block-Hierarchie erben und keine Layout-Abhängigkeiten haben. Ein ViewModel bekommt seine Abhängigkeiten über den Konstruktor injiziert und gibt Daten für das Template auf, ohne selbst HTML zu rendern.
Im Unit-Test instanziiert man den ViewModel direkt mit gemockten Abhängigkeiten und prüft, ob die öffentlichen Methoden die erwarteten Werte zurückgeben. Ein ViewModel, der Preise formatiert, ruft einen PriceCurrencyInterface-Mock auf und gibt das Ergebnis unverändert zurück – der Test prüft genau dieses Verhalten ohne den vollständigen Magento-Preis-Stack zu laden.
6. Mocking-Strategie für Magento-Abhängigkeiten
Magento-Klassen sind oft schwer zu instanziieren, weil sie tief verschachtelte Abhängigkeiten haben oder finale Klassen sind. PHPUnit 10 bietet createMock() für Interfaces und nicht-finale Klassen. Für finale Klassen braucht man entweder die Mockery-Bibliothek oder man schreibt ein eigenes Test-Double durch Implementierung des relevanten Interfaces. Im Magento-Kontext ist der zweite Weg oft sauberer, weil die meisten Klassen ein Interface implementieren.
Ein häufiger Fehler ist das Mocking von Klassen, die keine Interfaces implementieren, wie etwa AbstractModel-Subklassen. Hier ist es besser, das eigene Model-Interface zu definieren und dagegen zu testen. Die Regel lautet: Im Unit-Test immer gegen Interfaces mocken, nie gegen konkrete Klassen. Das ist nicht nur für die Testbarkeit besser, sondern zwingt auch zur sauberen Trennung zwischen Interface und Implementierung.
<?php
// File: app/code/Mironsoft/Catalog/Test/Unit/ViewModel/ProductPriceViewModelTest.php
declare(strict_types=1);
namespace Mironsoft\Catalog\Test\Unit\ViewModel;
use Magento\Framework\Pricing\PriceCurrencyInterface;
use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\Catalog\Api\Data\ProductInterface;
use Mironsoft\Catalog\ViewModel\ProductPriceViewModel;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
class ProductPriceViewModelTest extends TestCase
{
private PriceCurrencyInterface $currencyMock;
private ProductRepositoryInterface $productRepoMock;
private ProductPriceViewModel $viewModel;
protected function setUp(): void
{
$this->currencyMock = $this->createMock(PriceCurrencyInterface::class);
$this->productRepoMock = $this->createMock(ProductRepositoryInterface::class);
$this->viewModel = new ProductPriceViewModel(
priceCurrency: $this->currencyMock,
productRepository: $this->productRepoMock,
);
}
#[Test]
public function getFormattedPriceReturnsCurrencyFormattedString(): void
{
$product = $this->createMock(ProductInterface::class);
$product->method('getFinalPrice')->willReturn(19.99);
$this->currencyMock
->expects($this->once())
->method('format')
->with(19.99)
->willReturn('19,99 €');
$result = $this->viewModel->getFormattedPrice($product);
$this->assertSame('19,99 €', $result);
}
#[Test]
public function getFormattedPriceReturnsEmptyStringForZeroPrice(): void
{
$product = $this->createMock(ProductInterface::class);
$product->method('getFinalPrice')->willReturn(0.0);
$this->currencyMock->method('format')->willReturn('0,00 €');
$result = $this->viewModel->getFormattedPrice($product);
$this->assertSame('0,00 €', $result);
}
}
7. Constructor Property Promotion und Dependency Injection
PHP 8.4 mit Constructor Property Promotion macht den Code von ViewModels und Services erheblich kompakter. Statt separater Property-Deklaration und Konstruktor-Zuweisung schreibt man public readonly PriceCurrencyInterface $priceCurrency direkt im Konstruktor-Parameter. Das verändert auch den Testaufbau leicht: Die gemockten Objekte werden direkt im setUp() als Konstruktor-Argumente übergeben – benannte Argumente machen dabei explizit, welcher Parameter welchen Mock erhält.
Das Muster mit readonly-Properties verhindert, dass Dependencies nach der Konstruktion überschrieben werden, was im Test-Kontext zu saubereren Test-Setups führt. Ein ViewModel mit drei readonly-Dependencies braucht genau drei Mocks im setUp(). Jede Methode des ViewModel, die eine dieser Dependencies aufruft, ist direkt testbar ohne weitere Konfiguration. Das ist deutlich übersichtlicher als der alte Ansatz mit $this->objectManager->create() im Test.
8. Typische Fallstricke und wie man sie vermeidet
Der häufigste Fallstrick beim Testen von Magento-Repositories ist die Annahme, dass das Mocking des ResourceModel ausreicht. Tatsächlich haben viele Repositories weitere Abhängigkeiten wie CollectionFactory oder SearchResultsFactory, die ebenfalls gemockt werden müssen. Wenn man diese vergisst, bekommt man beim Ausführen des Tests keine klare Fehlermeldung, sondern einen TypeError oder einen Fatal error wegen fehlender Mock-Konfiguration.
Ein zweiter Fallstrick sind Magento-spezifische Klassen wie Phrase (für Übersetzungen) oder DataObject. Diese Klassen haben keine komplexen Abhängigkeiten und können direkt instanziiert werden – sie müssen nicht gemockt werden. Das reduziert den Mock-Overhead erheblich. Ein dritter Fallstrick: Wer createConfiguredMock() statt createMock() verwendet, muss alle Methoden-Returnwerte im Array angeben, was bei Interfaces mit vielen Methoden schnell unübersichtlich wird. Besser ist es, einzelne method()->willReturn()-Ketten nach Bedarf zu konfigurieren.
9. Vergleich: Was Unit-Tests leisten, was nicht
Unit-Tests für Repositories, Services und ViewModels sind kein Ersatz für Integrationstests. Sie prüfen die Logik der Klasse in Isolation, nicht das Zusammenspiel mit Datenbank, Cache oder Event-System. Ein Repository-Unit-Test kann nicht sicherstellen, dass das korrekte SQL generiert wird – das erfordert einen Datenbanktest. Was er sicherstellt: dass die richtigen Methoden in der richtigen Reihenfolge aufgerufen werden und dass Exceptions korrekt behandelt werden.
| Aspekt | Unit-Test (PHPUnit) | Integrationstest | Empfehlung |
|---|---|---|---|
| Geschäftslogik | Vollständig abdeckbar | Möglich, aber langsamer | Unit-Test bevorzugen |
| Datenbankabfragen | Nicht testbar | Vollständig testbar | Integrationstest nötig |
| Exception-Handling | Ideal testbar | Aufwändig | Unit-Test bevorzugen |
| Event-Dispatching | Nur Aufruf prüfbar | Observer-Effekte prüfbar | Je nach Anforderung |
| Ausführungsgeschwindigkeit | Millisekunden | Sekunden bis Minuten | Unit-Test für CI-Feedback |
Die Faustregel lautet: Alles, was sich mit Mocks isolieren lässt und keine externen Systeme erfordert, gehört in den Unit-Test. Alles, was das reale Datenbankschema, den Magento-DI-Container oder das Event-System betrifft, ist Aufgabe des Integrationstests. Diese klare Trennung sorgt für eine schnelle Feedback-Schleife in der Entwicklung und eine zuverlässige Regressionssicherung im CI.
Mironsoft
Magento 2 Entwicklung, PHPUnit-Teststrategie und Code-Qualität
Magento-Code, der sicher refaktoriert werden kann?
Wir implementieren Unit-Test-Suiten für bestehende Magento-Module – Repositories, Services und ViewModels – und integrieren sie in die CI-Pipeline, damit jede Änderung sofort geprüft wird.
Test-Audit
Analyse bestehender Magento-Module auf Unit-Test-Lücken und kritische Pfade ohne Absicherung
Test-Implementierung
PHPUnit-Tests für Repositories, Service-Contracts und ViewModels nach modernen PHP-8.4-Standards
CI-Integration
Test-Suite in GitHub Actions oder GitLab CI integrieren mit Coverage-Report und Qualitätsschwellen
10. Zusammenfassung
PHPUnit-Tests für Repositories, Services und ViewModels in Magento 2 decken die wichtigste Schicht der Geschäftslogik ab, ohne den vollständigen Magento-Bootstrap zu benötigen. Das Ergebnis sind schnelle, deterministische Tests, die in unter einer Sekunde pro Klasse ausgeführt werden und in jeder CI-Pipeline ohne Datenbankverbindung funktionieren. Constructor Property Promotion in PHP 8.4 macht den Testaufbau kürzer und übersichtlicher – drei Mocks im setUp(), benannte Argumente im Konstruktor, fertig.
Die klare Trennung zwischen Unit-Test (Logik in Isolation) und Integrationstest (Datenbankinteraktion, Event-System) ist der entscheidende Faktor für eine wartbare Teststrategie. Wer Unit-Tests für alle öffentlichen Methoden von Repositories, Services und ViewModels hat, kann mit Sicherheit refaktorieren, Abhängigkeiten austauschen und neue Features implementieren – ohne die Angst, bestehende Funktionalität zu brechen.
Repositories, Services und ViewModels testen — Das Wichtigste auf einen Blick
Teststruktur
app/code/Vendor/Module/Test/Unit/ mit eigenem Bootstrap – kein Datenbankzugang, nur PHP-Autoloader und PHPUnit-Mocks.
Mocking-Regel
Immer gegen Interfaces mocken, nicht gegen konkrete Klassen. createMock(InterfaceName::class) ist der Standardweg in PHPUnit 10.
PHP 8.4 Vorteil
Constructor Property Promotion + benannte Argumente = kompakter setUp() ohne Redundanz. readonly-Properties verhindern versehentliches Überschreiben.
Abgrenzung
Unit-Test: Logik, Exception-Handling, Methodenaufrufe. Integrationstest: SQL, Event-System, DI-Container. Nicht vermischen.