Every unit test ninja faces troubles whenever he wants to test some real class functionality. Have you ever invoked toHtml() method of some block or beforeLoad() method of any collection? If you want to do it, you will need to mock a lot of classes that depend on other classes and so on. You would need to mock all of them, which would take a lot of time and effort. In a long run, you may blow up deadlines and it would really suck. Yes, you can try to change your testing strategy to avoid invoking such methods. But what if you still need to test them? Let’s explore how I addressed this issue.
One day I needed to test my block classes. There were many small logics in templates related to block rendering and I needed to test them. I thought: “OK, this must be easy. I will simply invoke the toHtml() method of each block and then I will check whether the result of block rendering contains any substrings. It won’t take a lot of time”. I was happy with that idea and I got started with unit testing.
As I discovered, that wasn’t the case. The Magento\Framework\View\Element\Template\Context class provides most of the dependencies for the block and it requires a lot of them:
/** * * @param \Magento\Framework\App\RequestInterface $request * @param \Magento\Framework\View\LayoutInterface $layout * @param \Magento\Framework\Event\ManagerInterface $eventManager * @param \Magento\Framework\UrlInterface $urlBuilder * @param \Magento\Framework\App\CacheInterface $cache * @param \Magento\Framework\View\DesignInterface $design * @param \Magento\Framework\Session\SessionManagerInterface $session * @param \Magento\Framework\Session\SidResolverInterface $sidResolver * @param \Magento\Framework\App\Config\ScopeConfigInterface $scopeConfig * @param \Magento\Framework\View\Asset\Repository $assetRepo * @param \Magento\Framework\View\ConfigInterface $viewConfig * @param \Magento\Framework\App\Cache\StateInterface $cacheState * @param \Psr\Log\LoggerInterface $logger * @param \Magento\Framework\Escaper $escaper * @param \Magento\Framework\Filter\FilterManager $filterManager * @param \Magento\Framework\Stdlib\DateTime\TimezoneInterface $localeDate * @param \Magento\Framework\Translate\Inline\StateInterface $inlineTranslation * @param \Magento\Framework\Filesystem $filesystem * @param \Magento\Framework\View\FileSystem $viewFileSystem * @param \Magento\Framework\View\TemplateEnginePool $enginePool * @param \Magento\Framework\App\State $appState * @param \Magento\Store\Model\StoreManagerInterface $storeManager * @param \Magento\Framework\View\Page\Config $pageConfig * @param \Magento\Framework\View\Element\Template\File\Resolver $resolver * @param \Magento\Framework\View\Element\Template\File\Validator $validator * * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( \Magento\Framework\App\RequestInterface $request, \Magento\Framework\View\LayoutInterface $layout, \Magento\Framework\Event\ManagerInterface $eventManager, \Magento\Framework\UrlInterface $urlBuilder, \Magento\Framework\App\CacheInterface $cache, \Magento\Framework\View\DesignInterface $design, \Magento\Framework\Session\SessionManagerInterface $session, \Magento\Framework\Session\SidResolverInterface $sidResolver, \Magento\Framework\App\Config\ScopeConfigInterface $scopeConfig, \Magento\Framework\View\Asset\Repository $assetRepo, \Magento\Framework\View\ConfigInterface $viewConfig, \Magento\Framework\App\Cache\StateInterface $cacheState, \Psr\Log\LoggerInterface $logger, \Magento\Framework\Escaper $escaper, \Magento\Framework\Filter\FilterManager $filterManager, \Magento\Framework\Stdlib\DateTime\TimezoneInterface $localeDate, \Magento\Framework\Translate\Inline\StateInterface $inlineTranslation, \Magento\Framework\Filesystem $filesystem, \Magento\Framework\View\FileSystem $viewFileSystem, \Magento\Framework\View\TemplateEnginePool $enginePool, \Magento\Framework\App\State $appState, \Magento\Store\Model\StoreManagerInterface $storeManager, \Magento\Framework\View\Page\Config $pageConfig, \Magento\Framework\View\Element\Template\File\Resolver $resolver, \Magento\Framework\View\Element\Template\File\Validator $validator ) { parent::__construct( $request, $layout, $eventManager, $urlBuilder, $cache, $design, $session, $sidResolver, $scopeConfig, $assetRepo, $viewConfig, $cacheState, $logger, $escaper, $filterManager, $localeDate, $inlineTranslation ); $this->resolver = $resolver; $this->validator = $validator; $this->_storeManager = $storeManager; $this->_appState = $appState; $this->_logger = $logger; $this->_filesystem = $filesystem; $this->_viewFileSystem = $viewFileSystem; $this->enginePool = $enginePool; $this->pageConfig = $pageConfig; }
By default, we can create mock instances of classes. Also, using the Magento\Framework\TestFramework\Unit\Helper\ObjectManager class, we have an ability to create real objects, but we need to prepare constructor arguments for them by ourselves. Well, using only these approaches, we would check all of the dependencies and decide which ones we need to mock and which ones we do not. Then we would check the dependencies of classes, which we need to get real objects. This way, we would check the whole tree of dependencies recursively. It would involve a lot of effort and time, and we would write hundreds lines of code.
No, thanks! This is not my way. The test code should be simple and easy to read. Hundreds of code lines don’t make it easier. There must be a better solution.
I started to look into the default object manager of Magento 2. It creates real instances of classes with their dependencies automatically. It seemed like it did exactly what I needed.
The default object manager is an instance of Magento\Framework\ObjectManagerInterface. To create it, we need to use the Magento\Framework\App\ObjectManagerFactory. The main problem is that the factory requires some basic dependencies (like Magento\Framework\App\Filesystem\DirectoryList, Magento\Framework\Filesystem\Driver\File, Magento\Framework\Filesystem\DriverPool) of Magento2 and we can’t get access to them in the PhpUnit context. Though it’s OK, we can still create them by ourselves:
<?php namespace Vendor\Module\Test\Unit; use Magento\Framework\App\ObjectManagerFactory as AppObjectManagerFactory; use Magento\Framework\Config\File\ConfigFilePool; use Magento\Framework\ObjectManagerInterface; use Magento\Framework\Filesystem\DriverPool; use Magento\Framework\Filesystem\Driver\File; use Magento\Framework\App\Filesystem\DirectoryList; /** * Class ObjectManagerFactory */ class ObjectManagerFactory { /** * @return ObjectManagerInterface */ public function create($args = []) { return $this->createObjectManagerFactory()->create($args); } /** * @return AppObjectManagerFactory */ protected function createObjectManagerFactory() { $driverPool = $this->createDriverPool(); $directoryList = $this->createDirectoryList(); $configFilePool = $this->createConfigFilePool(); return new AppObjectManagerFactory($directoryList, $driverPool, $configFilePool); } /** * @return DriverPool */ protected function createDriverPool() { return new DriverPool([ 'file' => File::class ]); } /** * @return DirectoryList */ protected function createDirectoryList() { return new DirectoryList(BP, [ 'base' => [ 'path' => BP ] ]); } /** * @return ConfigFilePool */ protected function createConfigFilePool() { return new ConfigFilePool(); } }
There is the same snippet on GitHubGist.
I created this file in the Test\Unit directory of my module. I always create files, which are related to abstract functionality of tests, there.
Let’s see how to use this class. First of all, this class just creates a factory for the object manager. Then the factory will create a real object manager from Magento 2 that we can configure according to our needs:
/** * @return void */ protected function initRealObjectManager() { $realObjectManagerFactory = new ObjectManagerFactory(); $this->realObjectManager = $realObjectManagerFactory->create(); $frontendConfigurations = $this->realObjectManager ->get(ConfigLoader::class) ->load(Area::AREA_FRONTEND); $this->realObjectManager->configure($frontendConfigurations); }
Please, pay attention that we should load configuration for the required area. Otherwise, we will only load the basic configurations (they are stored in the app/etc/di.xml file).
Also, some classes require configured Magento\Framework\App\State singleton. We can fix it like this:
$this->appState = $this->realObjectManager->get(AppState::class); $this->appState->setAreaCode(Area::AREA_FRONTEND);
Obviously, an area for the App\State instance should be the same as the one you have used to load configurations from.
Let’s try to test our block with our new tool:
<?php namespace Feefo\Reviews\Test\Unit\Block; use Magento\Framework\App\Area; use Magento\Framework\App\State as AppState; use Magento\Framework\ObjectManagerInterface; use Magento\Framework\View\Element\Template\Context as TemplateContext; use Magento\Framework\Filesystem; use Magento\Framework\View\Element\Template\File\Resolver as FileResolver; use Magento\Framework\View\Element\Template\File\Validator as TemplateFileValidator; use Magento\Framework\View\TemplateEnginePool; use Magento\Framework\App\ObjectManager\ConfigLoader; use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; use Vendor\Module\Block\Block as TestingBlock; use Vendor\Module\Test\Unit\ObjectManagerFactory as TestObjectManagerFactory; /** * Class BlockTest */ class BlockTest extends \PHPUnit_Framework_TestCase { /** * Object Manager Instance * * @var TestObjectManagerFactory */ protected $objectManager; /** * @var TestingBlock */ protected $block; /** * @var ObjectManagerInterface */ protected $realObjectManager; /** * @var AppState */ protected $appState; /** * @return void */ public function setUp() { $this->objectManager = new ObjectManager($this); $this->initRealObjectManager(); $this->appState = $this->realObjectManager->get(AppState::class); $this->appState->setAreaCode(Area::AREA_FRONTEND); $arguments = [ 'data' => [], ]; $context = $this->createTemplateContext(); $arguments['context'] = $context; $this->block = $this->objectManager->getObject(TestingBlock::class, $arguments); } /** * Actual test method * * @return void */ public function testBlockOutput() { // ... preparing $output = $this->block->toHtml(); self::assertContains("atwix", $output); } /** * @return void */ protected function initRealObjectManager() { $realObjectManagerFactory = new TestObjectManagerFactory(); $this->realObjectManager = $realObjectManagerFactory->create(); $frontendConfigurations = $this->realObjectManager ->get(ConfigLoader::class) ->load(Area::AREA_FRONTEND); $this->realObjectManager->configure($frontendConfigurations); } /** * @return TemplateContext */ protected function createTemplateContext() { $contextArguments = $this->objectManager->getConstructArguments(TemplateContext::class); $contextArguments['filesystem'] = $this->createFilesystemObject(); $contextArguments['resolver'] = $this->createFileResolver(); $contextArguments['validator'] = $this->createTemplateFileValidator(); $contextArguments['enginePool'] = $this->createEnginePool(); $contextArguments['storeManager'] = $this->createStoreManagerMock(); return $this->objectManager->getObject(TemplateContext::class, $contextArguments); } /** * @return Filesystem */ protected function createFilesystemObject() { $filesystem = $this->realObjectManager->create(Filesystem::class); return $filesystem; } /** * @return FileResolver */ protected function createFileResolver() { $fileResolver = $this->realObjectManager->create(FileResolver::class); return $fileResolver; } /** * @return TemplateFileValidator */ protected function createTemplateFileValidator() { return $this->realObjectManager->create(TemplateFileValidator::class); } /** * @return TemplateEnginePool */ protected function createEnginePool() { return $this->realObjectManager->create(TemplateEnginePool::class); } }
As a result, we can create real instance of our dependencies just in one line. It saved my time, and I find it useful for every unit testing ninja!
Testing shouldn’t be hard and take a lot of time. Testing should be fun. We have earned it!
You may also want to read: