Unit Tests: Mocking von Repositories und Services
Unit Tests: Mocking von Repositories und Services
~8 Min. Lesezeit Zuletzt aktualisiert am 9. August 2026
PointsCalculator hatte in Kapitel 91 genau eine Abhängigkeit. Die meisten Klassen dieses Moduls haben mehr - Observer\AwardPointsOnOrderPlaced aus Kapitel 30 injiziert neun davon: einen Service Contract (PointsLedgerRepositoryInterface), zwei weitere Repositories, drei eigene Services (PointsCalculator, CategoryBonusResolver, LoyaltyConfig) und drei Framework-Klassen. Genau diese Klasse eignet sich deshalb als Vorlage dafür, wie Mocking im größeren Maßstab aussieht.
Stub vs. Mock: eine Begriffsklärung
createMock() erzeugt in PHPUnit technisch immer dasselbe Double-Objekt - der Unterschied zwischen "Stub" und "Mock" liegt allein in der Verwendung. Ein Stub liefert nur einen kanonischen Rückgabewert (willReturn()) und wird nie selbst geprüft. Ein Mock trägt eine Erwartung (expects($this->never()), expects($this->once())) und lässt den Test fehlschlagen, wenn diese Erwartung nicht erfüllt wird - unabhängig vom Rückgabewert.
Zwei Guard Clauses als erstes Testziel
AwardPointsOnOrderPlaced::awardPoints() aus Kapitel 30 hat zwei frühe Ausstiege, bevor irgendein Repository berührt wird: Gäste-Bestellungen erhalten keine Punkte, und eine bereits mit Punkten versehene Order (Idempotenz-Guard) wird übersprungen. Beide Fälle lassen sich testen, ohne auch nur eine einzige Bestellposition aufzubauen - genau der Fall, in dem Mocking seinen Wert am deutlichsten zeigt: neun Abhängigkeiten, aber keine einzige davon muss für diese beiden Tests mehr als "nicht aufgerufen" wissen.
<?php
declare(strict_types=1);
namespace Mironsoft\Loyalty\Test\Unit\Observer;
use Magento\Customer\Api\CustomerRepositoryInterface;
use Magento\Framework\Event;
use Magento\Framework\Event\Observer as EventObserver;
use Magento\Framework\Stdlib\DateTime\DateTime;
use Magento\Sales\Api\OrderRepositoryInterface;
use Magento\Sales\Model\Order;
use Mironsoft\Loyalty\Api\Data\PointsLedgerInterfaceFactory;
use Mironsoft\Loyalty\Api\PointsLedgerRepositoryInterface;
use Mironsoft\Loyalty\Model\Config\LoyaltyConfig;
use Mironsoft\Loyalty\Model\Service\CategoryBonusResolver;
use Mironsoft\Loyalty\Model\Service\PointsCalculator;
use Mironsoft\Loyalty\Observer\AwardPointsOnOrderPlaced;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
/**
* Unit tests for the two guard clauses in AwardPointsOnOrderPlaced (chapter 30) -
* guests never earn points, and an already-awarded order is skipped. Neither test
* needs a single order item, which is exactly why they're a good first target:
* nine dependencies, none of which has to do more than prove it was never called.
*/
class AwardPointsOnOrderPlacedTest extends TestCase
{
private AwardPointsOnOrderPlaced $observerUnderTest;
/**
* @var PointsLedgerRepositoryInterface&MockObject
*/
private PointsLedgerRepositoryInterface $pointsLedgerRepositoryMock;
/**
* @var LoggerInterface&MockObject
*/
private LoggerInterface $loggerMock;
/**
* Builds the observer with nine mocked collaborators - interfaces via
* createMock() on the interface, concrete services the exact same way, since
* createMock() disables the real constructor either way (see chapter 91).
*
* @return void
*/
protected function setUp(): void
{
$this->pointsLedgerRepositoryMock = $this->createMock(PointsLedgerRepositoryInterface::class);
$this->loggerMock = $this->createMock(LoggerInterface::class);
$this->observerUnderTest = new AwardPointsOnOrderPlaced(
$this->createMock(PointsCalculator::class),
$this->createMock(CategoryBonusResolver::class),
$this->createMock(LoyaltyConfig::class),
$this->pointsLedgerRepositoryMock,
$this->createMock(PointsLedgerInterfaceFactory::class),
$this->createMock(OrderRepositoryInterface::class),
$this->createMock(CustomerRepositoryInterface::class),
$this->createMock(DateTime::class),
$this->loggerMock
);
}
/**
* Wraps a mocked order in real (not mocked) Event/Observer objects - both are
* plain DataObject subclasses with no dependencies of their own, so building
* the real thing is simpler than mocking it.
*
* @param Order&MockObject $orderMock Order double carried as the event's "order" data.
* @return EventObserver
*/
private function observerWithOrder(Order $orderMock): EventObserver
{
$event = new Event(['order' => $orderMock]);
return new EventObserver(['event' => $event]);
}
/**
* @return void
*/
public function testGuestOrderNeverAwardsPoints(): void
{
$orderMock = $this->createMock(Order::class);
$orderMock->method('getCustomerIsGuest')->willReturn(true);
$this->pointsLedgerRepositoryMock->expects($this->never())->method('save');
$this->loggerMock->expects($this->never())->method('error');
$this->observerUnderTest->execute($this->observerWithOrder($orderMock));
}
/**
* @return void
*/
public function testAlreadyAwardedOrderIsSkipped(): void
{
$orderMock = $this->createMock(Order::class);
$orderMock->method('getCustomerIsGuest')->willReturn(false);
$orderMock->method('getCustomerId')->willReturn(42);
$orderMock->method('getData')->with('loyalty_points_earned')->willReturn(120);
$this->pointsLedgerRepositoryMock->expects($this->never())->method('save');
$this->loggerMock->expects($this->never())->method('error');
$this->observerUnderTest->execute($this->observerWithOrder($orderMock));
}
}Tipp: Event und Observer im Beispiel oben werden bewusst nicht gemockt, sondern echt instanziiert - beides sind reine DataObject-Unterklassen ohne eigene Konstruktor-Abhängigkeiten. Nicht jede Magento-Klasse muss zum Double werden; wo ein echtes Objekt genauso billig und genauso einfach ist, macht es den Test lesbarer.
Achtung: execute() aus Kapitel 30 fängt jede \Throwable intern ab und loggt sie nur - ein Aufruf mit einem falsch konfigurierten Mock (etwa getData() ohne with()-Einschränkung, das dann für getCustomerId()-artige Aufrufe null statt eines Werts liefert) schlägt deshalb nicht mit einer PHP-Fehlermeldung fehl, sondern lautlos mit einer verpassten expects($this->never())->method('error')-Prüfung. Genau deshalb prüft testGuestOrderNeverAwardsPoints() zusätzlich, dass der Logger niemals aufgerufen wird - sonst könnte ein grüner Test in Wahrheit eine verschluckte Exception verbergen.
Was hier bewusst nicht getestet wird
Der eigentliche Happy Path - eine echte Bestellung mit Positionen, Produkten, einem Kategorie-Bonus und einer tatsächlich gespeicherten TYPE_EARN-Ledger-Zeile - fehlt in diesem Kapitel bewusst. Ihn vollständig zu mocken würde bedeuten, praktisch jede Methode von Order, OrderItem, Product und CustomerInterface einzeln zu stubben, ohne dass ein einziger Framework-Baustein (EAV-Attribute, Event-Dispatching, echte Datenpersistenz) tatsächlich mitläuft. Kapitel 93 erklärt, warum genau dieser Fall ein Integrationstest ist - kein Unit Test.