Vinai Kopp

Magento Expert, Developer & Trainer

  • 06. The Action Controller TDD Kata

    April 4, 2016

    Mage2Katas

    This kata is about creating a new Action Controller using TDD.

    We will use unit tests to flesh out the behavior of a simple action controller step by step, and we will use mocks to design the way it interacts with the business model layer classes.

    Because we are using unit tests, the tests will not tell us if the class works correctly within Magento.

    The tests serve a different purposes, which is to ensure we don’t make any dumb mistakes, and to enable us to easily change the code in future with confidence that we didn’t introduce any bugs. They can also serve to detect PHP version incompatibilities, and help us not to write code we don’t need.
    Tests like these can also serve as living documentation of the system being tested.

    We don’t want to put any business logic directly into the action controller, because that would couple our business logic to the delivery mechanism, namely, the web.

    The action controllers simply are an entry point into the application.
    All an action controller should do is to convert the request data into a format that is independent of HTTP, and pass it on to the next application layer.
    This could be a DTO, but for the kata we will simply use a PHP array, which I would generally avoid for more complex scenarios.
    Beyond dealing with input arguments, action controllers may also do some error handling or initiate further routing.

    Lets define our goal for the action controller TDD kata.

    For the purpose of this kata, lets assume the controller will validate a given request is a POST request, and if so, pass the request parameters to an application layer use case class.
    In case of incomplete request parameters we want to return an appropriate error result, otherwise, if the process doesn’t throw an exception, we want to redirect the visitor to the homepage.

    So we will to do some request validation and some delegation and error handling.

    The module name will be Mage2Kata_ActionController.
    I already created the module by following the steps of the module config kata and the route config kata.

    So I’m assuming that the route already is correctly configured, and that a skeleton action controller class already exists.

    <?php
     
    namespace Mage2Kata\ActionController\Controller\Index;
     
    use Magento\Framework\App\Action\Action;
     
    class Index extends Action
    {
        public function execute()
        {
        }
    }

    Time to add functionality to the new action. The first rule of TDD requires us to create a test first, so lets create a dedicated test case in the file Test/Unit/Controller/Index/IndexTest.php.

    Because the controller class already exists, lets jump right in and write a test that ensures the execute() method returns a ResultInterface instance.

    <?php
     
    namespace Mage2Kata\ActionController\Controller\Index;
     
    use Magento\Framework\App\Action\Context as ActionContext;
    use Magento\Framework\Controller\ResultInterface;
     
    class IndexTest extends \PHPUnit_Framework_TestCase
    {
        /**
         * @var Index
         */
        private $controller;
     
        protected function setUp()
        {
            $mockContext = $this->getMock(ActionContext::class, [], [], '', false);
            $this->controller = new Index($mockContext);
        }
     
        public function testReturnsResultInstance()
        {
            $this->assertInstanceOf(ResultInterface::class, $this->controller->execute());
        }
    }

    The test fails because currently our execute() method returns nothing.

    We need to create and return a ResultInterface object. Since we are going to send some specific HTTP response codes under some circumstances, lets add add a factory to the class dependencies and return the object it creates.

    <?php
     
    namespace Mage2Kata\ActionController\Controller\Index;
     
    use Magento\Framework\App\Action\Context as ActionContext;
    use Magento\Framework\Controller\Result\Raw as RawResult;
    use Magento\Framework\Controller\Result\RawFactory as RawResultFactory;
    use Magento\Framework\Controller\ResultInterface;
     
    class IndexTest extends \PHPUnit_Framework_TestCase
    {
        /**
         * @var Index
         */
        private $controller;
     
        /**
         * @var RawResult|\PHPUnit_Framework_MockObject_MockObject
         */
        private $mockRawResult;
     
        protected function setUp()
        {
            $this->mockRawResult = $this->getMock(RawResult::class, [], [], '', false);
     
            $mockRawResultFactory = $this->getMock(RawResultFactory::class, ['create'], [], '', false);
            $mockRawResultFactory->method('create')->willReturn($this->mockRawResult);
     
            $mockContext = $this->getMock(ActionContext::class, [], [], '', false);
     
            $this->controller = new Index($mockContext, $mockRawResultFactory);
        }
     
        public function testReturnsResultInstance()
        {
            $this->assertInstanceOf(ResultInterface::class, $this->controller->execute());
        }
    }

    The Raw and RawFactory classes are imported and aliased to RawResult and RawResultFactory respectively.

    The mock factory then is passed to the action controller constructor.

    To make the first test pass, all we need to do now is to receive the factory in the controller action constructor and return the object created by the factory from the execute() method.

    <?php
     
    namespace Mage2Kata\ActionController\Controller\Index;
     
    use Magento\Framework\App\Action\Action;
    use Magento\Framework\App\Action\Context;
    use Magento\Framework\Controller\Result\RawFactory as RawResultFactory;
     
    class Index extends Action
    {
        /**
         * @var RawResultFactory
         */
        private $rawResultFactory;
     
        public function __construct(Context $context, RawResultFactory $rawResultFactory)
        {
            parent::__construct($context);
            $this->rawResultFactory = $rawResultFactory;
        }
     
        public function execute()
        {
            return $this->rawResultFactory->create();
        }
    }

    Now we are back in the green state, lets think about what to do next.

    Much of our actions behavior revolves around the request method. I would like us to be able to specify the request method in a test.
    So our next step is to add a mock request to the test class.

    The request object actually is already added as an indirect dependency to by the parent Action class we are inheriting via the Context instance.
    So instead of adding it as a new constructor argument for our action controller, we have to add it to the Context instance.

    <?php
     
    namespace Mage2Kata\ActionController\Controller\Index;
     
    use Magento\Framework\App\Action\Context as ActionContext;
    use Magento\Framework\Controller\Result\Raw as RawResult;
    use Magento\Framework\Controller\Result\RawFactory as RawResultFactory;
    use Magento\Framework\Controller\ResultInterface;
    use Magento\Framework\HTTP\PhpEnvironment\Request as HttpRequest;
     
    class IndexTest extends \PHPUnit_Framework_TestCase
    {
        /**
         * @var Index
         */
        private $controller;
     
        /**
         * @var RawResult|\PHPUnit_Framework_MockObject_MockObject
         */
        private $mockRawResult;
     
        /**
         * @var HttpRequest|\PHPUnit_Framework_MockObject_MockObject
         */
        private $mockRequest;
     
        protected function setUp()
        {
            $this->mockRawResult = $this->getMock(RawResult::class, [], [], '', false);
            $this->mockRequest = $this->getMock(HttpRequest::class, [], [], '', false);
     
            $mockRawResultFactory = $this->getMock(RawResultFactory::class, ['create'], [], '', false);
            $mockRawResultFactory->method('create')->willReturn($this->mockRawResult);
     
            $mockContext = $this->getMock(ActionContext::class, [], [], '', false);
            $mockContext->method('getRequest')->willReturn($this->mockRequest);
     
            $this->controller = new Index($mockContext, $mockRawResultFactory);
        }
     
        public function testReturnsResultInstance()
        {
            $this->mockRequest->method('getMethod')->willReturn('POST');
            $this->assertInstanceOf(ResultInterface::class, $this->controller->execute());
        }
    }

    The test still passes, which means we didn’t break anything, even though it doesn’t allow us to write new production code (we need a failing test for that).
    However, now we are in a good shape for the next test, which is to ensure the execute() method returns a 405 Method not allowed HTTP response code for any non-POST request.

    public function testReturns405MethodNotAllowedForNonPostRequests()
    {
        $this->mockRequest->method('getMethod')->willReturn('GET');
        $this->mockRawResult->expects($this->once())->method('setHttpResponseCode')->with(405);
        $this->controller->execute();
    }

    And finally we are back in the red state.

    For now we can fake it ‘till we make it. Lets only add the minimum required code to make the test pass. Further tests will allow us to actually check for the request method in the production code.
    By avoiding writing the check if it is a POST request right now, we ensure we are not adding any functionality that is not covered by our tests.

    public function execute()
    {
        $result = $this->rawResultFactory->create();
        $result->setHttpResponseCode(405);
        return $result;
    }

    Which brings us back to green.

    Next, lets take care of writing the code that fetches the request parameters from the request object and then delegate further processing to some application layer use case class.
    That class is expected to throw an exception if the input arguments are incomplete.

    For the kata, lets just use a dummy non-existant class called UseCase.

    We will need to add this UseCase dependency first.

    protected function setUp()
    {
        $this->mockRawResult = $this->getMock(RawResult::class, [], [], '', false);
        $this->mockRequest = $this->getMock(HttpRequest::class, [], [], '', false);
        $this->mockUseCase = $this->getMock(UseCase::class, ['doSomething'], [], '', false);
     
        $mockRawResultFactory = $this->getMock(RawResultFactory::class, ['create'], [], '', false);
        $mockRawResultFactory->method('create')->willReturn($this->mockRawResult);
     
        $mockContext = $this->getMock(ActionContext::class, [], [], '', false);
        $mockContext->method('getRequest')->willReturn($this->mockRequest);
     
        $this->controller = new Index($mockContext, $mockRawResultFactory, $this->mockUseCase);
    }

    Note that the UseCase class does not really exist. In a real work scenario this might be a class that represents some business logic action, for example scheduling some DB table optimization or placing a custom product into the cart.
    Such a real use case class would be named accordingly. UseCase is a dreadful, non-descriptive, class name.
    But I can’t come up with a better example right now ¯\_(ツ)_/¯.

    Here is a test for that checks for the correct HTTP response code in the result if a request parameter is missing.

    public function testReturns400BadRequestIfRequiredParametersAreMissing()
    {
        $testException = new RequiredParametersMissingException('Test Exception: required parameters missing');
        $this->mockUseCase->method('doSomething')->willThrowException($testException);
        $this->mockRawResult->expects($this->once())->method('setHttpResponseCode')->with(400);
        $this->controller->execute();
    }

    Running the test causes a failure:

    Fatal error: Class 'Mage2Kata\ActionController\Controller\Index\RequiredParametersMissingException' not found

    Lets create the exception class.

    <?php
     
    namespace Mage2Kata\ActionController\Model\Exception;
     
    class RequiredParametersMissingException extends \RuntimeException
    {
    }

    I decided to put it into the Model\Exception namespace, since the theoretical UseCase class that throws it would probably live under Model, too.

    If we re-run the test again, it finally fails for the right reason:

    Failed asserting that 405 matches expected 400.
    Expected :400
    Actual   :405

    To make it pass lets first move the existing result generation into a method getMethodNotAllowedResult().

    private function getMethodNotAllowedResult()
    {
        $result = $this->rawResultFactory->create();
        $result->setHttpResponseCode(405);
        return $result;
    }

    And then we can wrap that into an if branch so the 405 response is only returned if the request method does not match POST, and finally add a new default behavior.

    public function execute()
    {
        if ($this->getRequest()->getMethod() !== 'POST') {
            return $this->getMethodNotAllowedResult();
        }
        $result = $this->rawResultFactory->create();
        $result->setHttpResponseCode(400);
        return $result;
    }

    We didn’t even have to call the doSomething() method on UseCase to make our test pass.
    Lets change that by modifying our test as follows.

    public function testReturns400BadRequestIfRequiredParametersAreMissing()
    {
        $incompleteParameters = [];
        $this->mockRequest->method('getMethod')->willReturn('POST');
        $this->mockRequest->method('getParams')->willReturn($incompleteParameters);
     
        $testException = new RequiredParametersMissingException('Test Exception: required parameters missing');
        $this->mockUseCase->expects($this->once())
            ->method('doSomething')
            ->with($incompleteParameters)
            ->willThrowException($testException);
     
        $this->mockRawResult->expects($this->once())->method('setHttpResponseCode')->with(400);
     
        $this->controller->execute();
    }

    Note we configured the mock to expect the method doSomething() to be called exactly once with an array that matches the one returned by the requests getParams() method.

    This brings us back to the red state, so we can change the production code that it matches the expectations of the test.

    public function execute()
    {
        if ($this->getRequest()->getMethod() !== 'POST') {
            return $this->getMethodNotAllowedResult();
        }
        try {
            $this->useCase->doSomething($this->getRequest()->getParams());
        } catch (RequiredParametersMissingException $exception) {
            $result = $this->rawResultFactory->create();
            $result->setHttpResponseCode(400);
            return $result;
        }
     
        $result = $this->rawResultFactory->create();
        return $result;
    }

    Well, all is green and we are getting somewhere, but the current state of things isn’t very nice. Lets refactor the controller class a bit while we are in green state.

    <?php
     
    namespace Mage2Kata\ActionController\Controller\Index;
     
    use Mage2Kata\ActionController\Model\Exception\RequiredParametersMissingException;
    use Magento\Framework\App\Action\Action;
    use Magento\Framework\App\Action\Context;
    use Magento\Framework\Controller\Result\Raw as RawResult;
    use Magento\Framework\Controller\Result\RawFactory as RawResultFactory;
     
    class Index extends Action
    {
        /**
         * @var RawResultFactory
         */
        private $rawResultFactory;
     
        /**
         * @var UseCase
         */
        private $useCase;
     
        public function __construct(Context $context, RawResultFactory $rawResultFactory, UseCase $useCase)
        {
            parent::__construct($context);
            $this->rawResultFactory = $rawResultFactory;
            $this->useCase = $useCase;
        }
     
        public function execute()
        {
            return !$this->isPostRequest() ?
                $this->getMethodNotAllowedResult() :
                $this->processRequestAndRedirect();
        }
     
        private function processRequestAndRedirect()
        {
            try {
                $this->useCase->doSomething($this->getRequest()->getParams());
                $result = $this->rawResultFactory->create();
                return $result;
            } catch (RequiredParametersMissingException $exception) {
                return $this->getBadRequestResult();
            }
        }
     
        private function getMethodNotAllowedResult()
        {
            $result = $this->rawResultFactory->create();
            $result->setHttpResponseCode(405);
            return $result;
        }
     
        private function getBadRequestResult()
        {
            $result = $this->rawResultFactory->create();
            $result->setHttpResponseCode(400);
            return $result;
        }
     
        private function isPostRequest()
        {
            return $this->getRequest()->getMethod() === 'POST';
        }
    }

    The last missing bit of logic is that we want to redirect to the homepage if all went well. Currently we are simply returning a dummy RawResult object.

    In order to make that a Redirect result, we need to inject a RedirectFactory instance into our controller. Just like the Request, this RedirectFactory actually already is part of the action Context.
    However, the Action class does not provide a getter method to access the redirect factory, just a protected property, which I don’t consider a reliable part of the Action class interface to depend on. Instead, lets add the instance to a private class property.

    But first, lets mock the Redirect result and the RedirectFactory and then add the latter to the mock Context in the test.

    <?php
     
    namespace Mage2Kata\ActionController\Controller\Index;
     
    use Mage2Kata\ActionController\Model\Exception\RequiredParametersMissingException;
    use Magento\Framework\App\Action\Context as ActionContext;
    use Magento\Framework\Controller\Result\Raw as RawResult;
    use Magento\Framework\Controller\Result\RawFactory as RawResultFactory;
    use Magento\Framework\Controller\Result\Redirect as RedirectResult;
    use Magento\Framework\Controller\Result\RedirectFactory as RedirectResultFactory;
    use Magento\Framework\Controller\ResultInterface;
    use Magento\Framework\HTTP\PhpEnvironment\Request as HttpRequest;
     
    class IndexTest extends \PHPUnit_Framework_TestCase
    {
        /**
         * @var Index
         */
        private $controller;
     
        /**
         * @var RawResult|\PHPUnit_Framework_MockObject_MockObject
         */
        private $mockRawResult;
     
        /**
         * @var HttpRequest|\PHPUnit_Framework_MockObject_MockObject
         */
        private $mockRequest;
     
        /**
         * @var UseCase|\PHPUnit_Framework_MockObject_MockObject
         */
        private $mockUseCase;
     
        /**
         * @var RedirectResult|\PHPUnit_Framework_MockObject_MockObject
         */
        private $mockRedirectResult;
     
        protected function setUp()
        {
            $this->mockRawResult = $this->getMock(RawResult::class, [], [], '', false);
            $this->mockRequest = $this->getMock(HttpRequest::class, [], [], '', false);
            $this->mockUseCase = $this->getMock(UseCase::class, ['doSomething'], [], '', false);
            $this->mockRedirectResult = $this->getMock(RedirectResult::class, [], [], '', false);
     
            $mockRawResultFactory = $this->getMock(RawResultFactory::class, ['create'], [], '', false);
            $mockRawResultFactory->method('create')->willReturn($this->mockRawResult);
     
            $mockRedirectResultFactory = $this->getMock(RedirectResultFactory::class, ['create'], [], '', false);
            $mockRedirectResultFactory->method('create')->willReturn($this->mockRedirectResult);
     
            $mockContext = $this->getMock(ActionContext::class, [], [], '', false);
            $mockContext->method('getRequest')->willReturn($this->mockRequest);
            $mockContext->method('getResultRedirectFactory')->willReturn($mockRedirectResultFactory);
     
            $this->controller = new Index($mockContext, $mockRawResultFactory, $this->mockUseCase);
        }
     
        // ... unchanged code removed for readability ...
     
        public function testRedirectsToHomepageForValidRequests()
        {
            $this->mockRequest->method('getMethod')->willReturn('POST');
            $this->mockRequest->method('getParams')->willReturn(['foo_id' => 123]);
     
            $this->assertSame($this->mockRedirectResult, $this->controller->execute());
        }
    }

    Note the changes in the setUp() method as well as the new test method testRedirectsToHomepageForValidRequests().

    Now, in the action controller constructor, we can assign the redirect factory from the Context to a class property.

    public function __construct(Context $context, RawResultFactory $rawResultFactory, UseCase $useCase)
    {
        parent::__construct($context);
        $this->rawResultFactory = $rawResultFactory;
        $this->useCase = $useCase;
        $this->redirectResultFactory = $context->getResultRedirectFactory();
    }

    Then we use that factory to instantiate the result if no exception is thrown by the UseCase.

    private function processRequestAndRedirect()
    {
        try {
            $this->useCase->doSomething($this->getRequest()->getParams());
            return $this->redirectResultFactory->create();
        } catch (RequiredParametersMissingException $e) {
            return $this->getBadRequestResult();
        }
    }

    Rerunning the tests shows we are all green.

    We are almost there, the only missing bit is to tell the redirect result instance where to redirect to.
    Lets add a new line to testRedirectsToHomepageForValidRequests(), where we set an expectation on $this->mockRedirectResult that the setPath() method is called.

    public function testRedirectsToHomepageForValidRequests()
    {
        $this->mockRequest->method('getMethod')->willReturn('POST');
        $this->mockRequest->method('getParams')->willReturn(['foo_id' => 123]);
     
        $this->mockRedirectResult->expects($this->once())->method('setPath');
     
        $this->assertSame($this->mockRedirectResult, $this->controller->execute());
    }

    And last we can make that change to the action controller.

    private function processRequestAndRedirect()
    {
        try {
            $this->useCase->doSomething($this->getRequest()->getParams());
            $redirect = $this->redirectResultFactory->create();
            $redirect->setPath('/');
            return $redirect;
        } catch (RequiredParametersMissingException $e) {
            return $this->getBadRequestResult();
        }
    }

    To stick with our little convention to have dedicated methods for creating the result objects, lets extract the creation of the redirect result into its own method, too.

    Here is my complete version of the action controller class.

    <?php
     
    namespace Mage2Kata\ActionController\Controller\Index;
     
    use Mage2Kata\ActionController\Model\Exception\RequiredParametersMissingException;
    use Magento\Framework\App\Action\Action;
    use Magento\Framework\App\Action\Context;
    use Magento\Framework\Controller\Result\Raw as RawResult;
    use Magento\Framework\Controller\Result\RawFactory as RawResultFactory;
    use Magento\Framework\Controller\Result\Redirect as RedirectResult;
    use Magento\Framework\Controller\Result\RedirectFactory as RedirectResultFactory;
     
    class Index extends Action
    {
        /**
         * @var RawResultFactory
         */
        private $rawResultFactory;
     
        /**
         * @var UseCase
         */
        private $useCase;
     
        /**
         * @var RedirectResultFactory
         */
        private $redirectResultFactory;
     
        public function __construct(Context $context, RawResultFactory $rawResultFactory, UseCase $useCase)
        {
            parent::__construct($context);
            $this->rawResultFactory = $rawResultFactory;
            $this->useCase = $useCase;
            $this->redirectResultFactory = $context->getResultRedirectFactory();
        }
     
        public function execute()
        {
            return !$this->isPostRequest() ?
                $this->getMethodNotAllowedResult() :
                $this->processRequestAndRedirect();
        }
     
        private function processRequestAndRedirect()
        {
            try {
                $this->useCase->doSomething($this->getRequest()->getParams());
                return $this->getRedirectResult();
            } catch (RequiredParametersMissingException $e) {
                return $this->getBadRequestResult();
            }
        }
     
        private function getMethodNotAllowedResult()
        {
            $result = $this->rawResultFactory->create();
            $result->setHttpResponseCode(405);
            return $result;
        }
     
        private function getBadRequestResult()
        {
            $result = $this->rawResultFactory->create();
            $result->setHttpResponseCode(400);
            return $result;
        }
     
        private function getRedirectResult()
        {
            $redirect = $this->redirectResultFactory->create();
            $redirect->setPath('/');
            return $redirect;
        }
     
        private function isPostRequest()
        {
            return $this->getRequest()->getMethod() === 'POST';
        }
    }

    Rerun the tests one more time to ensure our refactoring did not break anything.

    And with that the Action Controller TDD Mage2Kata is complete.

    I hope you enjoyed it!

    As always, please let me know if you have any feedback, either as a comment below the video on youtube, or on twitter.

    Thanks for following!

    comments powered by Disqus