Unit Tests: Mocking Repositories and Services
Unit Tests: Mocking Repositories and Services
~8 Min. Lesezeit Zuletzt aktualisiert am August 9, 2026
PointsCalculator had exactly one dependency in chapter 91. Most classes in this module have more - Observer\AwardPointsOnOrderPlaced from chapter 30 injects nine: one service contract (PointsLedgerRepositoryInterface), two more repositories, three custom services (PointsCalculator, CategoryBonusResolver, LoyaltyConfig), and three framework classes. That makes it exactly the right class to show what mocking looks like at a larger scale.
Stub vs. mock: a terminology note
createMock() technically always produces the same double object in PHPUnit - the difference between "stub" and "mock" lies purely in how it's used. A stub only supplies a canned return value (willReturn()) and is never itself verified. A mock carries an expectation (expects($this->never()), expects($this->once())) and fails the test if that expectation isn't met - regardless of the return value.
Two guard clauses as a first target
AwardPointsOnOrderPlaced::awardPoints() from chapter 30 has two early exits before any repository is touched at all: guest orders never earn points, and an order that already carries points (the idempotency guard) is skipped. Both cases can be tested without building a single order item - exactly the case where mocking shows its value most clearly: nine dependencies, none of which needs to know more than "was never called" for these two tests.
<?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 and Observer in the example above are deliberately not mocked, they're built for real - both are plain DataObject subclasses with no constructor dependencies of their own. Not every Magento class has to become a double; where a real object is just as cheap and just as simple, it makes the test more readable.
Achtung: execute() from chapter 30 catches every \Throwable internally and only logs it - a call with a misconfigured mock (say, getData() with no with() constraint, which then returns null instead of a value for calls like getCustomerId()) therefore does not fail with a PHP error, it fails silently by missing an expects($this->never())->method('error') check. That's exactly why testGuestOrderNeverAwardsPoints() additionally asserts the logger is never called - otherwise a green test could actually be hiding a swallowed exception.
What's deliberately not tested here
The actual happy path - a real order with items, products, a category bonus, and an actually persisted TYPE_EARN ledger row - is deliberately missing from this chapter. Fully mocking it would mean stubbing practically every method of Order, OrderItem, Product, and CustomerInterface one by one, without a single framework building block (EAV attributes, event dispatching, real data persistence) actually running along. Chapter 93 explains why exactly this case is an integration test - not a unit test.