*** Check out Grokking Magento ***

Vinai Kopp

Magento Expert, Developer & Trainer

  • 14. The ORM Entity Kata

    November 19, 2016

    Mage2Katas

    In this episode we will create a

    A bit of related Trivia:
    In Magento 1 event observers are considered the “best practice” approach to modifying core behavior, because they don’t conflict as easily with other modules as class rewrites do.
    However, I believe that in Magento 2 the best way to apply a customization is by using a plugin on an method that has been marked as stable with an @api PHPDoc annotation.
    Only if no stable method to intercept is available, then an event observer should be considered.

    In this kata we will test drive writing an event observer for the checkout_cart_product_add_after event.

    The purpose of our event observer is to copy a custom attribute magento_se_points from the product to the quote item. (In my experience that is a pretty common use case).

    The event is currently (version 2.1.0) dispatched by
    \Magento\Checkout\Model\Cart::addProduct()

    $this->_eventManager->dispatch(
        'checkout_cart_product_add_after',
        ['quote_item' => $result, 'product' => $product]
    );

    The quote item is an instance of \Magento\Quote\Model\Quote\Item and $product contains a product model.

    When testing an event observer, there actually are 3 distinct things to test:

    1. The configuration
    2. The code in the observer
    3. That the observer interacts with Magento correctly

    We will write a separate test for each one of those things.
    Item 1 and 3 are integration tests. The second item can be done using unit tests.

    To bootstrap the kata I’ve created a module Mage2Kata_EventObserver with Setup\UpgradeData class that adds the magento_se_points attribute. You might want to do the same before you start.

    For a change (compared to the other Mage2Kata episodes), lets start by writing the final integration test first this time (the third item in the list above).

    As all tests do the integration test can be split into 3 parts: arrange, act and assert:

    • Arrange:
      • Create a product model.
      • Create a quote item model.
      • Set a value for magento_se_points on the product.
    • Act:
      • Dispatch the checkout_cart_product_add_after.
    • Assert:
      • Check the attribute value is now set on the quote item.

    Here is the test code for the file Test/Integration/EventObserverEdge2EdgeTest:

    <?php
     
    namespace Mage2Kata\EventObserver\Test\Integration;
     
    use Magento\Catalog\Model\Product;
    use Magento\Framework\Event\ManagerInterface as EventManager;
    use Magento\Quote\Model\Quote\Item as QuoteItem;
    use Magento\TestFramework\ObjectManager;
     
    class EventObserverEdge2EdgeTest extends \PHPUnit_Framework_TestCase
    {
        private function dispatchEvent($event, array $eventData)
        {
            /** @var EventManager $eventManager */
            $eventManager = ObjectManager::getInstance()->create(EventManager::class);
            $eventManager->dispatch($event, $eventData);
        }
     
        public function testCopiesCustomAttributeFromProductToQuoteItem()
        {
            $quoteItem = ObjectManager::getInstance()->create(QuoteItem::class);
            $product = ObjectManager::getInstance()->create(Product::class);
            $product->setCustomAttribute('magento_se_points', 500);
     
            $this->dispatchEvent(
                'checkout_cart_product_add_after',
                ['quote_item' => $quoteItem, 'product' => $product]
            );
     
            $this->assertSame(500, $quoteItem->getData('magento_se_points'));
        }
    }

    This test of course fails since we haven’t written any non-testing code yet.
    When it passes that will be the signal for us that we are done with the kata.

    So lets get to it.

    Two things have to be taken care of to complete the kata:

    1. Create the event observer class.
    2. Add the event observer configuration.

    We can do these steps in any order. I prefer TDDing the observer class first before writing the configuration that uses it.

    Lets create the class
    \Mage2Kata\EventObserver\Observer\CheckoutCartProductAddAfterObserverTest within the Test/Unit directory.
    I don’t want to write down every small step (they will be part of the recording though).
    To summarize, the first steps that I follow during this part of the kata are

    1. Check the class CheckoutCartProductAddAfterObserver exists
    2. Check the class implements the \Magento\Framework\Event\ObserverInterface
    3. Refactor to alias the import of \Magento\Framework\Event\Observer as Event for kicks

    Here is the test class after those 3 steps:

    <?php
     
    namespace Mage2Kata\EventObserver\Observer;
     
    use Magento\Framework\Event\ObserverInterface;
     
    class CheckoutCartProductAddAfterObserverTest extends \PHPUnit_Framework_TestCase
    {
        public function testImplementsTheEventObserverInterface()
        {
            $this->assertInstanceOf(
                ObserverInterface::class,
                new CheckoutCartProductAddAfterObserver()
            );
        }
    }

    And this is the class under test:

    <?php
     
    namespace Mage2Kata\EventObserver\Observer;
     
    use Magento\Framework\Event\Observer as Event;
    use Magento\Framework\Event\ObserverInterface;
     
    class CheckoutCartProductAddAfterObserver implements ObserverInterface
    {
        public function execute(Event $event)
        {   
        }
    }

    Now it’s time to add the actual logic within execute().

    We need to create a handful of test doubles that will make up the object graph passed into the observer:

    • The Product
    • The Quote Item
    • The custom Attribute instance that will be returned by $product->getCustomAttribute()
    • The Observer instance that is passed to execute().
    public function testSetsTheMagentoSEPointsOnTheQuoteItem()
    {
        $mockQuoteItem = $this->getMock(QuoteItem::class, [], [], '', false);
        $mockProduct = $this->getMock(Product::class, [], [], '', false);
     
        $mockAttribute = $this->getMock(AttributeInterface::class);
        $mockAttribute->method('getValue')->willReturn(123);
        $mockProduct->method('getCustomAttribute')->with('magento_se_points')->willReturn($mockAttribute);
     
        $mockEvent = $this->getMock(Event::class, [], [], '', false);
        $mockEvent->method('getData')->willReturnMap([
            ['product', null, $mockProduct],
            ['quote_item', null, $mockQuoteItem],
        ]);
    }

    Note 1: as I did in the class under test above, I’ve also aliased Magento\Framework\Event\Observer as Event.
    The reason is only that I find an Observer instance being passed into my Observer and assigned to a variable $observer confusing. Passing an Event to an observer makes more sense to me, so I enjoy the aliasing.

    Note 2: Purists may argue that we have only created dummy and stub test doubles so far, but I think the distinction between types of test doubles is mostly academic and of no real relevance during day to day work. The word mock is commonly used as a synonym for test double and I’m fine with that.

    Now lets have our test call the method we want to write:

    (new CheckoutCartProductAddAfterObserver())->execute($mockEvent);

    And now we are ready to add an assertion to our test.

    $mockQuoteItem->expects($this->once())->method('setData')
        ->with('magento_se_points', 123);

    And finally we have a failing test.

    Time to make the observer copy the attribute from the product to the quote item:

    <?php
     
    namespace Mage2Kata\EventObserver\Observer;
     
    use Magento\Catalog\Model\Product;
    use Magento\Framework\Event\Observer as Event;
    use Magento\Framework\Event\ObserverInterface;
    use Magento\Quote\Model\Quote\Item as QuoteItem;
     
    class CheckoutCartProductAddAfterObserver implements ObserverInterface
    {
        public function execute(Event $event)
        {
            /** @var Product $product */
            $product = $event->getData('product');
            /** @var QuoteItem $quoteItem */
            $quoteItem = $event->getData('quote_item');
     
            $points = $product->getCustomAttribute('magento_se_points');
            $quoteItem->setData('magento_se_points', $points->getValue());
        }
    }

    And with that our unit test passes and our observer is done.
    Time to add the event observer configuration.

    Lets create a new integration test for this
    \Mage2Kata\EventObserver\Test\Integration\EventObserverConfigTest.

    <?php
     
    namespace Mage2Kata\EventObserver\Test\Integration;
     
    use Mage2Kata\EventObserver\Observer\CheckoutCartProductAddAfterObserver;
    use Magento\Framework\Event\ConfigInterface as EventObserverConfig;
    use Magento\TestFramework\ObjectManager;
     
    class EventObserverConfigTest extends \PHPUnit_Framework_TestCase
    {
        public function testCheckoutCartProductAddAfterEventObserverIsConfigured()
        {
            /** @var EventObserverConfig $observerConfig */
            $observerConfig = ObjectManager::getInstance()->create(EventObserverConfig::class);
            $observers = $observerConfig->getObservers('checkout_cart_product_add_after');
            $this->assertArrayHasKey('mage2kata_eventobserver', $observers);
            $this->assertSame(
                ltrim(CheckoutCartProductAddAfterObserver::class, '\\'),
                $observers['mage2kata_eventobserver']['instance']
            );
        }
    }

    Note: We are currently creating the observer to observe the event in all execution scopes (frontend, adminhtml, webapi_rest, etc…). If we where creating the event observer to only listen during frontend requests for example, we could use the @magentoAppArea frontend test annotation.

    Since this test is failing, we are now allowed to add the etc/events.xml configuration.

    <?xml version="1.0"?>
    <config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
            xsi:noNamespaceSchemaLocation="urn:magento:framework:Event/etc/events.xsd">
        <event name="checkout_cart_product_add_after">
            <observer name="mage2kata_eventobserver"
                      instance="Mage2Kata\EventObserver\Observer\CheckoutCartProductAddAfterObserver"/>
        </event>
    </config>

    Time to flush the integration test sandbox cache directory as we have changed some configuration, run the integration tests… and we are green!

    Creating the event observer class and the config made our initial integration test pass, too, which means we are done.

    Thank you for following along.
    If you have any questions, please leave a comment.

    Happy testing!

    comments powered by Disqus

Read Grokking Magento now!

"The best technical reading I had since years by @VinaiKopp"

Tim Bezhashvyly
Magento Developer