Vinai Kopp

Magento Expert, Developer & Trainer

  • 03. The Around Interceptor Kata

    February 22, 2016

    Mage2Katas

    The scenario for this kata is that we want to create a plugin that gets called around the CustomerRepositoryInterface::save() method.

    The plugin should call an external API method whenever a new customer registers. For this kata we are not concerned about the details of the external API, it will be enough if we simply call a method on a class under the right conditions.

    We will assume the plugin already is configured. If not, you can refer to the previous episode, “The Plugin Config Kata”.

    If you still have the previous kata’s code lying around, lets rename the integration test class to CustomerRepositoryPluginIntegrationTest. This will make PHPStorm less confused about two classes with the same name.

    The we will start by creating a unit test in Mage2Kata/Interceptor/Test/Unit/Plugin/CustomerRepositoryPluginTest.php.

    As always, I start with a dummy test to confirm I have set up my execution environment correctly.

    <?php
     
    namespace Mage2Kata\Interceptor\Plugin;
     
    class CustomerRepositoryPluginTest extends \PHPUnit_Framework_TestCase
    {
        public function testNothing()
        {
            $this->assertSame(1, 1);
        }
    }

    It succeeds naturally, so we are ready to write real test code.

    As a first step, I want a test which forces me to create the class. It is just another temporary test, which I’ll remove once the class exists.

    public function testItCanBeInstantiated()
    {
        new CustomerRepositoryPlugin();
    }

    The test is red.

    To make the test pass we have to create the plugin class within the file Mage2Kata/Interceptor/Plugin/CustomerRepositoryPlugin.php.

    <?php
     
    namespace Mage2Kata\Interceptor\Plugin;
     
    class CustomerRepositoryPlugin
    {
     
    }

    Lets replace the test with one that will force us to add the aroundSave() method. We will have to prepare the four arguments for the method that though.

    • The subject of the plugin, a CustomerRepositoryInterface instance.
    • The $proceed closure to allow the plugin to continue to flow of control to the next plugin or the original CustomerRepository::save() method.
    • The CustomerInterface instance which is the expected parameter of the CustomerRepositoryInterface::save() method.
    • The optional $passwordHash argument.

    We will use test doubles for the $subject and the $customer arguments.

    Just to change things up a little, we will implement the __invoke() method on the test and use itself as a test double for the $proceed callable. An alternative would be to use also mock or a closure.

    This is how the test looks with all four arguments in place.

    <?php
     
    namespace Mage2Kata\Interceptor\Plugin;
     
    use Magento\Customer\Api\CustomerRepositoryInterface;
    use Magento\Customer\Api\Data\CustomerInterface;
     
    class CustomerRepositoryPluginTest extends \PHPUnit_Framework_TestCase
    {
        /**
         * @var CustomerRepositoryPlugin
         */
        private $plugin;
     
        /**
         * @var CustomerRepositoryInterface|\PHPUnit_Framework_MockObject_MockObject
         */
        private $mockCustomerRepository;
     
        /**
         * @var CustomerInterface|\PHPUnit_Framework_MockObject_MockObject
         */
        private $mockCustomerToSave;
     
        /**
         * @var CustomerInterface|\PHPUnit_Framework_MockObject_MockObject
         */
        private $mockSavedCustomer;
     
        public function __invoke(CustomerInterface $customer, $passwordHash)
        {
            return $this->mockSavedCustomer;
        }
     
        protected function setUp()
        {
            $this->mockCustomerRepository = $this->getMock(CustomerRepositoryInterface::class);
            $this->mockCustomerToSave = $this->getMock(CustomerInterface::class);
            $this->mockSavedCustomer = $this->getMock(CustomerInterface::class);
     
            $this->plugin = new CustomerRepositoryPlugin();
        }
     
        public function testItHasAnAroundSaveMethod()
        {
            $customerRepository = $this->mockCustomerRepository;
            $proceed = $this;
            $customer = $this->mockCustomerToSave;
            $passwordHash = null;
            $this->plugin->aroundSave($customerRepository, $proceed, $customer, $passwordHash);
        }
    }

    And this puts us into a good spot ready to write more tests, and allows us to create the aroundSave() method in the plugin.

        public function aroundSave(
            CustomerRepositoryInterface $customerRepository,
            callable $proceed,
            CustomerInterface $customer,
            $passwordHash = null
        ) {
        }

    Check you got the right CustomerInterface import. It’s easy to get Magento\Customer\Test\Handler\Customer\CustomerInterface instead of the correct Magento\Customer\Api\Data\CustomerInterface.

    Now we want to check the plugin calls the $proceed callable.

    ...
    public function testItReturnsTheResultOfProceed()
    {
        $customerRepository = $this->mockCustomerRepository;
        $proceed = $this;
        $customer = $this->mockCustomerToSave;
        $passwordHash = null;
        $result = $this->plugin->aroundSave($customerRepository, $proceed, $customer, $passwordHash);
        $this->assertSame($this->mockSavedCustomer, $result);
    }

    To make the test pass, we simply change one line in our plugin:

    public function aroundSave(
        CustomerRepositoryInterface $customerRepository,
        callable $proceed,
        CustomerInterface $customer,
        $passwordHash = null
    ) {
        return $proceed($customer, $passwordHash);
    }

    Time to refactor. The plugin code looks okay, but I want to extract a callAroundSavePlugin() method in the test.

    ...
    private function callAroundSavePlugin()
    {
        $proceed = $this;
        $pwHash = null;
        return $this->plugin->aroundSave($this->mockCustomerRepository, $proceed, $this->mockCustomerToSave, $pwHash);
    }
    ...
    public function testItReturnsTheResultOfProceed()
    {
        $this->assertSame($this->mockSavedCustomer, $this->callAroundSavePlugin());
    }

    This test definitely has value, both in protecting me from messing up further down the road, and it also can serve as developer documentation.

    Lets continue by adding a test double for the class that calls the external API. We will use a mock of a non-existent class and assume it has a registerNewCustomer method.

    Our setUp method now looks like this:

    protected function setUp()
    {
        $this->mockCustomerRepository = $this->getMock(CustomerRepositoryInterface::class);
        $this->mockCustomerToSave = $this->getMock(CustomerInterface::class);
        $this->mockSavedCustomer = $this->getMock(CustomerInterface::class);
     
        $this->mockExternalApi = $this->getMock(ExternalCustomerApi::class, ['registerNewCustomer']);
        $this->plugin = new CustomerRepositoryPlugin($this->mockExternalApi);
    }

    We test that the external API is called like so:

    public function testItCallsTheExternalApiForNewCustomers()
    {
        $this->mockCustomerToSave->method('getId')->willReturn(null);
        $this->mockExternalApi->expects($this->once())->method('registerNewCustomer');
        $this->callAroundSavePlugin();
    }

    Of course the test fails. Lets make it green by adding a little more code to our plugin class.

    <?php
     
    namespace Mage2Kata\Interceptor\Plugin;
     
    use Magento\Customer\Api\CustomerRepositoryInterface;
    use Magento\Customer\Api\Data\CustomerInterface;
     
    class CustomerRepositoryPlugin
    {
        /**
         * @var ExternalCustomerApi
         */
        private $customerApi;
     
        public function __construct(ExternalCustomerApi $customerApi)
        {
            $this->customerApi = $customerApi;
        }
     
        public function aroundSave(
            CustomerRepositoryInterface $customerRepository,
            callable $proceed,
            CustomerInterface $customer,
            $passwordHash = null
        ) {
            $this->customerApi->registerNewCustomer();
            return $proceed($customer, $passwordHash);
        }
    }

    And all tests pass again.

    However, we only should be calling the API for new registrations. Lets add a test to check we don’t call the API for existing customers.

    public function testItDoesNotCallTheExternalApiForExistingCustomers()
    {
        $this->mockCustomerToSave->method('getId')->willReturn(33);
        $this->mockExternalApi->expects($this->never())->method('registerNewCustomer');
        $this->callAroundSavePlugin();
    }

    It is very similar to the previous test, but instead it specifies the customer getId will return an ID instead of null and registerNewCustomer should never be called.

    And here is how we can get this to pass:

    public function aroundSave(
        CustomerRepositoryInterface $customerRepository,
        callable $proceed,
        CustomerInterface $customer,
        $passwordHash = null
    ) {
        if ($customer->getId() === null) {
            $this->customerApi->registerNewCustomer();
        }
        return $proceed($customer, $passwordHash);
    }

    For the next step lets think about the arguments to the external API. Lets assume for now we simply want to send the ID of the freshly registered customer, maybe the external service will then poll Magento for whatever else it needs.

    We modify the existing test method testItCallsTheExternalApiForNewCustomers for this a little:

    public function testItCallsTheExternalApiForNewCustomers()
    {
        $customerId = 21;
        $this->mockCustomerToSave->method('getId')->willReturn(null);
        $this->mockSavedCustomer->method('getId')->willReturn($customerId);
        $this->mockExternalApi->expects($this->once())->method('registerNewCustomer')->with($customerId);
        $this->callAroundSavePlugin();
    }

    The API now expects to be called with the fake ID the saved customer mock returns.

    Lets satisfy those expectations.

    public function aroundSave(
        CustomerRepositoryInterface $customerRepository,
        callable $proceed,
        CustomerInterface $customer,
        $passwordHash = null
    ) {
        $isCustomerNew = $customer->getId() === null;
     
        /** @var CustomerInterface $savedCustomer */
        $savedCustomer = $proceed($customer, $passwordHash);
     
        if ($isCustomerNew) {
            $this->customerApi->registerNewCustomer($savedCustomer->getId());
        }
     
        return $savedCustomer;
    }

    And that completes the Around Interceptor Kata. Hope you enjoyed it.

    As always, please delete the code written during the kata, and remember to repeat it as long as you still feel you have to think about the individual steps.

    I would be delighted to hear your thoughts here on the blog, on Twitter, or in a comment on youtube. Thanks for reading!

    comments powered by Disqus