Magento 2 Experten — Hyvä Theme, Tailwind CSS & SEO aus einer Hand ›

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.

app/code/Mironsoft/Loyalty/Test/Unit/Observer/AwardPointsOnOrderPlacedTest.php
<?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.