Unit Tests unlock Single Responsibility Principle

I've started with the idea of covering an Observer class with the Unit Test. It should be straightforward, I thought. The CopyQuoteToOrder class is responsible for copying extension attributes from a Quote object to an Order object.

Little did I know, that by just following Test Driven Development, I can be sure the service class follows a Single Responsibility Principle. Why would I think that? Well, when I write a Unit Test, it is usually how I find out that a class has lots of responsibilities.

Take the below class as an example:

<?php declare(strict_types=1);

namespace Pronko\StoreCredit\Observer;

use Magento\Framework\Event;
use Magento\Framework\Event\Observer;
use Magento\Framework\Event\ObserverInterface;
use Magento\Quote\Api\Data\CartInterface;
use Magento\Sales\Api\Data\OrderInterface;

class CopyQuoteToOrder implements ObserverInterface
{
/**
* Execute method
*
* @param Observer $observer
* @return void
*/
public function execute(Observer $observer): void
{
$event = $observer->getEvent();
$quote = $this->getQuote($event);

$order = $this->getOrder($event);

$quoteAttributes = $quote->getExtensionAttributes();
$orderAttributes = $order->getExtensionAttributes();

$orderAttributes->setPronkoStoreCreditAmount(
$quoteAttributes->getPronkoStoreCreditAmount()
);
$orderAttributes->setBasePronkoStoreCreditAmount(
$quoteAttributes->getBasePronkoStoreCreditAmount()
);

$order->setExtensionAttributes($orderAttributes);
}

/**
* Get quote
*
* @param Event $event
* @return CartInterface
*/
private function getQuote(Event $event): CartInterface
{
return $event->getData('quote');
}

/**
* Get order
*
* @param Event $event
* @return OrderInterface
*/
private function getOrder(Event $event): OrderInterface
{
return $event->getData('order');
}
}

The CopyQuoteToOrder service class is responsible for the following:

  • Retrieving a Quote object from the Event object
  • Retrieving an Order object from the Event object
  • Retrieving Extension Attributes from the Quote object
  • Retrieving Extension Attributes from the Order object
  • Passing Quote Extension Attributes to the Extension Attributes object
  • Setting Extension Attributes back to the Order object

We can summarize the class responsibility by "copying quote custom fields to an order during order placement".

Let's start with the Unit Test. The responsibility of the Unit Test is to ensure that a unit that is represented by the class/method, is fully isolated from external calls and connections (in the case of the database). The testExecute() method covers the first 2 lines of the CopyQuoteToOrder::execute() method call.

<?php declare(strict_types=1);

namespace Pronko\StoreCredit\Test\Unit\Observer;

use Magento\Framework\Event;
use Magento\Framework\Event\Observer;
use Magento\Framework\Event\ObserverInterface;
use Magento\Quote\Api\Data\CartInterface;
use Magento\Sales\Api\Data\OrderInterface;
use PHPUnit\Framework\TestCase;
use Pronko\StoreCredit\Observer\CopyQuoteToOrder;

class CopyQuoteToOrderTest extends TestCase
{
public function testExecute(): void
{
$quote = $this->createMock(CartInterface::class);
$order = $this->createMock(OrderInterface::class);

$event = $this->createMock(Event::class);
$event->expects($this->exactly(2))
->method('getData')
->willReturnOnConsecutiveCalls($quote, $order);

$observer = $this->createMock(Observer::class);
$observer->expects($this->once())
->method('getEvent')
->willReturn($event);
$object = new CopyQuoteToOrder();
$object->execute($observer);
}
}

From my experience, the testExecute() method shows that further mocking of dependencies would introduce way more test code than it should for a simple Unit Test. By the end of the day, a developer should be motivated to write and then support Unit Tests.

The CopyQuoteToOrder class with the combination of Unit Tests brings great ideas into my head. What if I introduce a service class, that is responsible for copying extension attributes from one object to another? In addition to this, the CopyQuoteToOrder observer class is going to be responsible only for preparing the Quote and Order object and triggering the service class for copying the fields.

The AttributesCopierInterface provides a method for copying data from the CartInterface to OrderInterface extension attributes.

<?php declare(strict_types=1);

namespace Pronko\StoreCredit\Api;

use Magento\Quote\Api\Data\CartInterface;
use Magento\Sales\Api\Data\OrderInterface;

interface AttributesCopierInterface
{
/**
* Copies Store Credit extension attributes from Cart to Order object
*
* @param CartInterface $cart
* @param OrderInterface $order
* @return void
*/
public function copy(CartInterface $cart, OrderInterface $order): void;
}

The AttributesCopier service class implements AttributesCopierInterface::copy() method. Below is an example of the copy() method implementation.

<?php declare(strict_types=1);

namespace Pronko\StoreCredit\Service;

use Magento\Quote\Api\Data\CartInterface;
use Magento\Sales\Api\Data\OrderInterface;
use Pronko\StoreCredit\Api\AttributesCopierInterface;

class AttributesCopier implements AttributesCopierInterface
{
/**
* Copies Store Credit extension attributes from Cart to Order object
*
* @param CartInterface $cart
* @param OrderInterface $order
* @return void
*/
public function copy(CartInterface $cart, OrderInterface $order): void
{
$quoteAttributes = $cart->getExtensionAttributes();
$orderAttributes = $order->getExtensionAttributes();

$orderAttributes->setPronkoStoreCreditAmount(
$quoteAttributes->getPronkoStoreCreditAmount()
);
$orderAttributes->setBasePronkoStoreCreditAmount(
$quoteAttributes->getBasePronkoStoreCreditAmount()
);

$order->setExtensionAttributes($orderAttributes);
}
}

The CopyQuoteToOrder observer class should rely on the AttributesCopierInterface dependency and call the copy() method and pass both $quote and $order objects.

<?php declare(strict_types=1);

namespace Pronko\StoreCredit\Observer;

use Magento\Framework\Event;
use Magento\Framework\Event\Observer;
use Magento\Framework\Event\ObserverInterface;
use Magento\Quote\Api\Data\CartInterface;
use Magento\Sales\Api\Data\OrderInterface;
use Pronko\StoreCredit\Api\AttributesCopierInterface;

class CopyQuoteToOrder implements ObserverInterface
{
/**
* Construct
*
* @param AttributesCopierInterface $attributesCopier
*/
public function __construct(
private readonly AttributesCopierInterface $attributesCopier
) {}

/**
* Execute method
*
* @param Observer $observer
* @return void
*/
public function execute(Observer $observer): void
{
$event = $observer->getEvent();
$quote = $this->getQuote($event);
$order = $this->getOrder($event);

$this->attributesCopier->copy($quote, $order);
}

/**
* Get quote
*
* @param Event $event
* @return CartInterface
*/
private function getQuote(Event $event): CartInterface
{
return $event->getData('quote');
}

/**
* Get order
*
* @param Event $event
* @return OrderInterface
*/
private function getOrder(Event $event): OrderInterface
{
return $event->getData('order');
}
}

Going back to the CopyQuoteToOrderTest test class, we should add the mock of the AttributesCopierInterface and the test is ready to go.

<?php declare(strict_types=1);

namespace Pronko\StoreCredit\Test\Unit\Observer;

use Magento\Framework\Event;
use Magento\Framework\Event\Observer;
use Magento\Quote\Api\Data\CartInterface;
use Magento\Sales\Api\Data\OrderInterface;
use PHPUnit\Framework\TestCase;
use Pronko\StoreCredit\Api\AttributesCopierInterface;
use Pronko\StoreCredit\Observer\CopyQuoteToOrder;

class CopyQuoteToOrderTest extends TestCase
{
public function testExecute(): void
{
$quote = $this->createMock(CartInterface::class);
$order = $this->createMock(OrderInterface::class);

$event = $this->createMock(Event::class);
$event->expects($this->exactly(2))
->method('getData')
->willReturnOnConsecutiveCalls($quote, $order);

$observer = $this->createMock(Observer::class);
$observer->expects($this->once())
->method('getEvent')
->willReturn($event);

$attributesCopier = $this->createMock(AttributesCopierInterface::class);
$attributesCopier->expects($this->once())
->method('copy')
->with($quote, $order);
$object = new CopyQuoteToOrder($attributesCopier);
$object->execute($observer);
$this->assertTrue(true);
}
}

As we can notice from the above example, adding a Unit Test allowed us to think wider and introduce a dedicated class with its responsibility. An observer class acts like an entry point of the logic, and the only responsibility of the observer should be passing the control to a service class, in our example the AttributesCopierInterface. Unit Tests can then be easy to manage and support.