Loading README.md +2 −63 Original line number Diff line number Diff line Loading @@ -3,67 +3,6 @@ Collection of helper classes and functions to use in Drupal Unit tests, Kernel tests, Functional tests. ## EntityStubFactory See the main class \Drupal\test_helpers\UnitTestHelpers for main API. The main helper class is EntityStubFactory which allows quickly create an Entity Stub in Unit test functions, that will be immediately available to get via storage functions: - `EntityStorageInterface::load()`, - `EntityStorageInterface::loadMultiple()` - `EntityStorageInterface::loadByProperties()` - `EntityRepository::loadEntityByUuid()`. Here is an example: ```php /** use Drupal\test_helpers\EntityStubFactory; */ $entityStubFactory = new EntityStubFactory(); $node1Values = [ 'type' => 'article', 'title' => 'My cool article', 'body' => 'Very interesting article text.', 'field_tags' => [ ['target_id' => 1], ['target_id' => 3], ], ]; $node1Entity = $entityStubFactory->create(Node::class, $node1Values); $node1Entity->save(); $node1EntityId = $node1Entity->id(); $node1EntityUuid = $node1Entity->uuid(); $node1EntityType = $node1Entity->getEntityTypeId(); $node1LoadedById = \Drupal::service('entity_type.manager')->getStorage('node')->load($node1EntityId); $node1LoadedByUuid = \Drupal::service('entity.repository')->loadEntityByUuid($node1EntityType, $node1EntityUuid); $this->assertEquals(1, $node1LoadedById->id()); $this->assertEquals(1, preg_match('/^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i', $node1LoadedByUuid->uuid())); ``` More examples can be found in the unit test file: `tests/src/Unit/EntityStorageStubApiTest.php`. ## UnitTestHelpers Class `UnitTestHelpers` provides some utility functions: - `getAccessibleMethod()`: Gets an accessible method from class using reflection. - `getPluginDefinition()`: Parses the annotation for a Drupal Plugin class and generates a definition. - `addToContainer()`: Adds a new service to the Drupal container, if exists - reuse existing. - `getFromContainerOrCreate()`: Gets the service from the Drupal container, or creates a new one. - `bindClosureToClassMethod()`: Binds a closure function to a mocked class method. _It's yet in the early stage of development, so some features are implemented in ugly ways, "just to make them work as needed"._ See usage examples in the submodule `test_helpers_example`. src/EntityTypeManagerStub.php +10 −10 Original line number Diff line number Diff line Loading @@ -15,16 +15,16 @@ class EntityTypeManagerStub extends EntityTypeManager implements EntityTypeManag public function __construct() { $languageDefault = new LanguageDefault(['id' => 'en', 'name' => 'English']); UnitTestHelpers::addToContainer('language_manager', new LanguageManager($languageDefault)); UnitTestHelpers::addToContainer('entity_field.manager', new EntityFieldManagerStub()); UnitTestHelpers::addToContainer('entity_type.bundle.info', new EntityTypeBundleInfoStub()); UnitTestHelpers::addToContainer('entity.query.sql', new EntityQueryServiceStub()); UnitTestHelpers::addToContainer('string_translation', UnitTestHelpers::getStringTranslationStub()); UnitTestHelpers::addToContainer('plugin.manager.field.field_type', new FieldTypeManagerStub()); UnitTestHelpers::addToContainer('typed_data_manager', new TypedDataManagerStub()); UnitTestHelpers::addToContainer('uuid', new PhpUuid()); UnitTestHelpers::initService('language_manager', new LanguageManager($languageDefault)); UnitTestHelpers::initService('entity_field.manager', new EntityFieldManagerStub()); UnitTestHelpers::initService('entity_type.bundle.info', new EntityTypeBundleInfoStub()); UnitTestHelpers::initService('entity.query.sql', new EntityQueryServiceStub()); UnitTestHelpers::initService('string_translation', UnitTestHelpers::getStringTranslationStub()); UnitTestHelpers::initService('plugin.manager.field.field_type', new FieldTypeManagerStub()); UnitTestHelpers::initService('typed_data_manager', new TypedDataManagerStub()); UnitTestHelpers::initService('uuid', new PhpUuid()); /** @var \Drupal\Core\Entity\EntityRepositoryInterface|\PHPUnit\Framework\MockObject\MockObject $entityRepository */ $entityRepository = UnitTestHelpers::addToContainer('entity.repository', UnitTestHelpers::createMock(EntityRepositoryInterface::class)); $entityRepository = UnitTestHelpers::initService('entity.repository', UnitTestHelpers::createMock(EntityRepositoryInterface::class)); $entityRepository ->method('loadEntityByUuid') ->willReturnCallback(function ($entityTypeId, $uuid) { Loading @@ -37,7 +37,7 @@ class EntityTypeManagerStub extends EntityTypeManager implements EntityTypeManag ->method('getTranslationFromContext') ->will(UnitTestCaseWrapper::getInstance()->returnArgument(0)); // UnitTestHelpers::addToContainer('entity_type.manager', $this); // UnitTestHelpers::initService('entity_type.manager', $this); } public function findDefinitions() { Loading src/UnitTestHelpers.php +166 −104 Original line number Diff line number Diff line Loading @@ -7,7 +7,6 @@ use Drupal\Core\Cache\CacheTagsInvalidatorInterface; use Drupal\Core\Database\Query\ConditionInterface as QueryConditionInterface; use Drupal\Core\DependencyInjection\ContainerBuilder; use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Entity\EntityStorageInterface; use Drupal\Core\Entity\Query\ConditionInterface as EntityQueryConditionInterface; use Drupal\test_helpers\Stub\ModuleHandlerStub; use Drupal\test_helpers\Stub\TokenStub; Loading @@ -18,6 +17,8 @@ use PHPUnit\Framework\MockObject\MockObject; use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException; use Symfony\Component\Yaml\Yaml; // This trick is to prevent 'Undefined constant' warnings in code sniffers. defined('DRUPAL_ROOT') || define('DRUPAL_ROOT', ''); /** * Helper functions to simplify writing of Unit Tests. */ Loading @@ -33,6 +34,14 @@ class UnitTestHelpers { 'module_handler' => ModuleHandlerStub::class, ]; /** * The list of implemented custom stubs for services. */ const SERVICES_CUSTOM_STUBS_CALLBACKS = [ 'string_translation' => [self::class, 'getStringTranslationStub'], 'class_resolver' => [self::class, 'getClassResolverStub'], ]; /** * Gets a protected method from a class using reflection. */ Loading Loading @@ -144,7 +153,7 @@ class UnitTestHelpers { * @return object * The initialized class instance. */ public static function doTestCreateAndConstruct($class, array $createArguments = []): object { public static function createService($class, array $createArguments = []): object { $container = UnitTestHelpers::getContainer(); $classInstance = $class::create($container, ...$createArguments); $className = is_string($class) ? $class : get_class($class); Loading @@ -152,69 +161,6 @@ class UnitTestHelpers { return $classInstance; } /** * Performs matching of passed conditions with the query. */ public static function queryIsSubsetOf(object $query, object $queryExpected, $onlyListed = FALSE): bool { // @todo add checks for range, sort and other query parameters. return self::matchConditions($query->condition, $queryExpected->condition, $onlyListed); } /** * Performs matching of passed conditions with the query. */ public static function matchConditions(object $conditionsObject, object $conditionsExpectedObject, $onlyListed = FALSE): bool { if ($conditionsObject instanceof EntityQueryConditionInterface) { if (strcasecmp($conditionsObject->getConjunction(), $conditionsExpectedObject->getConjunction()) != 0) { return FALSE; } } elseif ($conditionsObject instanceof QueryConditionInterface) { if (strcasecmp($conditionsObject->conditions()['#conjunction'], $conditionsExpectedObject->conditions()['#conjunction']) != 0) { return FALSE; } } else { throw new \Exception("Conditions should implement Drupal\Core\Entity\Query\ConditionInterface or Drupal\Core\Database\Query\ConditionInterface."); } $conditions = $conditionsObject->conditions(); unset($conditions['#conjunction']); $conditionsExpected = $conditionsExpectedObject->conditions(); unset($conditionsExpected['#conjunction']); $conditionsFound = []; foreach ($conditions as $condition) { foreach ($conditionsExpected as $conditionsExpectedDelta => $conditionExpected) { if (is_object($condition['field']) || is_object($conditionExpected['field'])) { if (!is_object($condition['field']) || !is_object($conditionExpected['field'])) { continue; } return self::matchConditions($condition['field'], $conditionExpected['field'], $onlyListed); } if (self::isNestedArraySubsetOf($condition, $conditionExpected)) { $conditionsFound[$conditionsExpectedDelta] = TRUE; } } } if (count($conditionsFound) != count($conditionsExpected)) { return FALSE; } if ($onlyListed && (count($conditions) != count($conditionsExpected))) { return FALSE; } return TRUE; } /** * Performs check if the actial array is a subset of expected. */ public static function isNestedArraySubsetOf($array, $subset): bool { if (!is_array($array) || !is_array($subset)) { return FALSE; } $result = array_uintersect_assoc($subset, $array, self::class . '::isValueSubsetOfCallback'); return $result == $subset; } /** * Gets a Drupal services container, or creates a new one. */ Loading @@ -227,13 +173,22 @@ class UnitTestHelpers { } /** * Adds a new service to the Drupal container, if exists - reuse existing. * Initializes a new service and adds to the Drupal container, if not exists. */ public static function addToContainer(string $serviceName, object $class, bool $override = FALSE): object { public static function initService(string $serviceName, $class = NULL, bool $override = FALSE): object { $container = self::getContainer(); $currentService = $container->has($serviceName) ? $container->get($serviceName) : new \stdClass(); if ($class === NULL) { $class = self::getServiceStub($serviceName); } elseif (is_string($class)) { $class = self::createMock($class); } elseif (!is_object($class)) { throw new \Exception("Class should be an object, string as path to class, or NULL."); } if ( (get_class($currentService) !== get_class($class)) || $override Loading @@ -244,23 +199,22 @@ class UnitTestHelpers { } /** * Gets a service class by name, using Drupal defaults or a custom YAML file. * Initializes services with creating mocks/stubs for not passed classes. */ public static function getServiceClassByName(string $serviceName, string $servicesYamlFile = NULL): string { if ($servicesYamlFile) { // @phpcs-ignore $services = Yaml::parseFile(DRUPAL_ROOT . '/' . $servicesYamlFile)['services']; $serviceClass = $services[$serviceName]['class'] ?? FALSE; public static function initServices(array $services, $clearContainer = FALSE): void { if ($clearContainer) { UnitTestHelpers::getContainer(TRUE); } foreach ($services as $key => $value) { // If we have only a service name - just reuse the default behavior. if (is_int($key)) { self::initService($value); } // If we have a service name in key and class in value - pass the class. else { require_once dirname(__FILE__) . '/includes/DrupalCoreServicesMap.data'; // @php-ignore $serviceClass = DRUPAL_CORE_SERVICES_MAP[$serviceName] ?? FALSE; self::initService($key, $value); } if (!$serviceClass) { throw new \Exception("Service '$serviceName' is missing in the list."); } return $serviceClass; } /** Loading @@ -269,7 +223,7 @@ class UnitTestHelpers { public static function createServiceMock(string $serviceName, string $servicesYamlFile = NULL): MockObject { $serviceClass = self::getServiceClassByName($serviceName, $servicesYamlFile); $service = UnitTestHelpers::createMock($serviceClass); self::addToContainer($serviceName, $service); self::initService($serviceName, $service); return $service; } Loading @@ -287,32 +241,23 @@ class UnitTestHelpers { } /** * Gets the class for a service, including current module implementations. * Gets a service class by name, using Drupal defaults or a custom YAML file. */ public static function getServiceStubClass(string $serviceName, bool $onlyCustomMocks = FALSE): object { if (isset(self::SERVICES_CUSTOM_STUBS[$serviceName])) { $serviceClass = self::SERVICES_CUSTOM_STUBS[$serviceName]; $service = new $serviceClass(); } elseif ($onlyCustomMocks) { throw new ServiceNotFoundException($serviceName); public static function getServiceClassByName(string $serviceName, string $servicesYamlFile = NULL): string { if ($servicesYamlFile) { $services = Yaml::parseFile(DRUPAL_ROOT . '/' . $servicesYamlFile)['services']; $serviceClass = $services[$serviceName]['class'] ?? FALSE; } else { $service = UnitTestHelpers::createServiceMock($serviceName); } return $service; require_once dirname(__FILE__) . '/includes/DrupalCoreServicesMap.data'; // This trick is to prevent 'Undefined constant' warnings in code sniffers. defined('DRUPAL_CORE_SERVICES_MAP') || define('DRUPAL_CORE_SERVICES_MAP', ''); $serviceClass = DRUPAL_CORE_SERVICES_MAP[$serviceName] ?? FALSE; } /** * Gets the service from the Drupal container, or creates a new one. */ public static function getOrCreateService(string $serviceName, $class): object { $container = UnitTestHelpers::getContainer(); if (!$container->has($serviceName)) { $container->set($serviceName, $class); \Drupal::setContainer($container); if (!$serviceClass) { throw new \Exception("Service '$serviceName' is missing in the list."); } return $container->get($serviceName); return $serviceClass; } /** Loading @@ -326,7 +271,7 @@ class UnitTestHelpers { /** * Gets or initializes an Entity Storage for a given Entity class name. */ public static function getEntityStorageStub(string $entityClassName): EntityStorageInterface { public static function getEntityStorageStub(string $entityClassName): EntityStorageStub { return self::getServiceStub('entity_type.manager')->stubGetOrCreateStorage($entityClassName); } Loading @@ -337,6 +282,73 @@ class UnitTestHelpers { self::getServiceStub('entity_type.manager'); } /* ************************************************************************ * * Helpers for queries. * ************************************************************************ */ /** * Performs matching of passed conditions with the query. */ public static function queryIsSubsetOf(object $query, object $queryExpected, $onlyListed = FALSE): bool { // @todo add checks for range, sort and other query parameters. return self::matchConditions($query->condition, $queryExpected->condition, $onlyListed); } /** * Performs matching of passed conditions with the query. */ public static function matchConditions(object $conditionsObject, object $conditionsExpectedObject, $onlyListed = FALSE): bool { if ($conditionsObject instanceof EntityQueryConditionInterface) { if (strcasecmp($conditionsObject->getConjunction(), $conditionsExpectedObject->getConjunction()) != 0) { return FALSE; } } elseif ($conditionsObject instanceof QueryConditionInterface) { if (strcasecmp($conditionsObject->conditions()['#conjunction'], $conditionsExpectedObject->conditions()['#conjunction']) != 0) { return FALSE; } } else { throw new \Exception("Conditions should implement Drupal\Core\Entity\Query\ConditionInterface or Drupal\Core\Database\Query\ConditionInterface."); } $conditions = $conditionsObject->conditions(); unset($conditions['#conjunction']); $conditionsExpected = $conditionsExpectedObject->conditions(); unset($conditionsExpected['#conjunction']); $conditionsFound = []; foreach ($conditions as $condition) { foreach ($conditionsExpected as $conditionsExpectedDelta => $conditionExpected) { if (is_object($condition['field']) || is_object($conditionExpected['field'])) { if (!is_object($condition['field']) || !is_object($conditionExpected['field'])) { continue; } return self::matchConditions($condition['field'], $conditionExpected['field'], $onlyListed); } if (self::isNestedArraySubsetOf($condition, $conditionExpected)) { $conditionsFound[$conditionsExpectedDelta] = TRUE; } } } if (count($conditionsFound) != count($conditionsExpected)) { return FALSE; } if ($onlyListed && (count($conditions) != count($conditionsExpected))) { return FALSE; } return TRUE; } /** * Performs check if the actial array is a subset of expected. */ public static function isNestedArraySubsetOf($array, $subset): bool { if (!is_array($array) || !is_array($subset)) { return FALSE; } $result = array_uintersect_assoc($subset, $array, self::class . '::isValueSubsetOfCallback'); return $result == $subset; } /* ************************************************************************ * * Wrappers for UnitTestCase functions to make them available statically. * ************************************************************************ */ Loading Loading @@ -417,10 +429,60 @@ class UnitTestHelpers { return ($value == $expected) ? 0 : -1; } /** * Gets the class for a service, including current module implementations. */ private static function getServiceStubClass(string $serviceName, bool $onlyCustomMocks = FALSE): object { if (isset(self::SERVICES_CUSTOM_STUBS[$serviceName])) { $serviceClass = self::SERVICES_CUSTOM_STUBS[$serviceName]; $service = new $serviceClass(); } elseif (isset(self::SERVICES_CUSTOM_STUBS_CALLBACKS[$serviceName])) { $serviceClassCallback = self::SERVICES_CUSTOM_STUBS_CALLBACKS[$serviceName]; $service = call_user_func_array($serviceClassCallback, []); } elseif ($onlyCustomMocks) { throw new ServiceNotFoundException($serviceName); } else { $service = UnitTestHelpers::createServiceMock($serviceName); } return $service; } /** * Disables a construtor calls to allow only static calls. */ private function __construct() { } /* ************************************************************************ * * Deprecations. * ************************************************************************ */ /** * Tests simple create() and __construct() functions. * * @deprecated in test_helpers:1.0.0-alpha7 and is removed from * test_helpers:1.0.0-beta1. Use UnitTestHelpers::initService(). * * @see https://www.drupal.org/project/test_helpers/issues/3315975 */ public static function doTestCreateAndConstruct($class, array $createArguments = []): object { return self::createService($class, $createArguments); } /** * Adds a new service to the Drupal container, if exists - reuse existing. * * @deprecated in test_helpers:1.0.0-alpha7 and is removed from * test_helpers:1.0.0-beta1. Use UnitTestHelpers::initService(). * * @see https://www.drupal.org/project/test_helpers/issues/3315975 */ public static function addToContainer(string $serviceName, $class = NULL, bool $override = FALSE): object { @trigger_error('Function addToContainer is renamed to initService in test_helpers:1.0.0-alpha7.', E_USER_DEPRECATED); return self::initService($serviceName, $class, $override); } } tests/src/Unit/UnitTestHelpersTest.php +54 −0 Original line number Diff line number Diff line Loading @@ -2,10 +2,15 @@ namespace Drupal\Tests\test_helpers\Unit; use Drupal\Core\Entity\Controller\EntityController; use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Entity\EntityTypeInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Routing\UrlGenerator; use Drupal\test_helpers\UnitTestHelpers; use Drupal\Tests\UnitTestCase; use PHPUnit\Framework\MockObject\MethodNameAlreadyConfiguredException; use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException; /** * Tests UnitTestHelpers functions. Loading @@ -19,6 +24,7 @@ class UnitTestHelpersTest extends UnitTestCase { * @covers ::getMockedMethod */ public function testGetMockedMethod() { /** @var \Drupal\Core\Entity\EntityInterface|\PHPUnit\Framework\MockObject\MockObject $mock */ $mock = $this->createMock(EntityInterface::class); $mock->method('label')->willReturn('foo'); $mock->method('id')->willReturn('42'); Loading Loading @@ -59,4 +65,52 @@ class UnitTestHelpersTest extends UnitTestCase { $this->assertSame(777, $mock->id()); } /** * @covers ::initServices */ public function testInitServices() { /** @var \Drupal\Core\Entity\EntityTypeInterface|\PHPUnit\Framework\MockObject\MockObject $entityType */ $entityType = $this->createMock(EntityTypeInterface::class); $entityType->method('getSingularLabel')->willReturn('my entity'); /** @var \Drupal\Core\Entity\EntityTypeManagerInterface|\PHPUnit\Framework\MockObject\MockObject $entityTypeManager */ $entityTypeManager = $this->createMock(EntityTypeManagerInterface::class); $entityTypeManager->method('getDefinition')->willReturn($entityType); UnitTestHelpers::initServices([ 'entity_type.manager' => $entityTypeManager, 'entity_type.bundle.info', 'renderer', 'string_translation', 'url_generator' => UrlGenerator::class, ]); // Checking initialized services. try { $service = UnitTestHelpers::doTestCreateAndConstruct(EntityController::class); $this->fail('Previous line should throw an exception.'); } catch (ServiceNotFoundException $e) { $this->assertEquals('You have requested a non-existent service "entity.repository".', $e->getMessage()); } UnitTestHelpers::initServices(['entity.repository']); // Testing the behavior on a real service with the 'create' function. $service = UnitTestHelpers::doTestCreateAndConstruct(EntityController::class); $result = $service->addTitle('my_entity'); $this->assertSame('Add my entity', $result->__toString()); // Checking resetting of the container. UnitTestHelpers::initServices(['entity.repository'], TRUE); try { $service = UnitTestHelpers::doTestCreateAndConstruct(EntityController::class); $this->fail('Previous line should throw an exception.'); } catch (ServiceNotFoundException $e) { $this->assertStringStartsWith('You have requested a non-existent service', $e->getMessage()); } } } Loading
README.md +2 −63 Original line number Diff line number Diff line Loading @@ -3,67 +3,6 @@ Collection of helper classes and functions to use in Drupal Unit tests, Kernel tests, Functional tests. ## EntityStubFactory See the main class \Drupal\test_helpers\UnitTestHelpers for main API. The main helper class is EntityStubFactory which allows quickly create an Entity Stub in Unit test functions, that will be immediately available to get via storage functions: - `EntityStorageInterface::load()`, - `EntityStorageInterface::loadMultiple()` - `EntityStorageInterface::loadByProperties()` - `EntityRepository::loadEntityByUuid()`. Here is an example: ```php /** use Drupal\test_helpers\EntityStubFactory; */ $entityStubFactory = new EntityStubFactory(); $node1Values = [ 'type' => 'article', 'title' => 'My cool article', 'body' => 'Very interesting article text.', 'field_tags' => [ ['target_id' => 1], ['target_id' => 3], ], ]; $node1Entity = $entityStubFactory->create(Node::class, $node1Values); $node1Entity->save(); $node1EntityId = $node1Entity->id(); $node1EntityUuid = $node1Entity->uuid(); $node1EntityType = $node1Entity->getEntityTypeId(); $node1LoadedById = \Drupal::service('entity_type.manager')->getStorage('node')->load($node1EntityId); $node1LoadedByUuid = \Drupal::service('entity.repository')->loadEntityByUuid($node1EntityType, $node1EntityUuid); $this->assertEquals(1, $node1LoadedById->id()); $this->assertEquals(1, preg_match('/^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i', $node1LoadedByUuid->uuid())); ``` More examples can be found in the unit test file: `tests/src/Unit/EntityStorageStubApiTest.php`. ## UnitTestHelpers Class `UnitTestHelpers` provides some utility functions: - `getAccessibleMethod()`: Gets an accessible method from class using reflection. - `getPluginDefinition()`: Parses the annotation for a Drupal Plugin class and generates a definition. - `addToContainer()`: Adds a new service to the Drupal container, if exists - reuse existing. - `getFromContainerOrCreate()`: Gets the service from the Drupal container, or creates a new one. - `bindClosureToClassMethod()`: Binds a closure function to a mocked class method. _It's yet in the early stage of development, so some features are implemented in ugly ways, "just to make them work as needed"._ See usage examples in the submodule `test_helpers_example`.
src/EntityTypeManagerStub.php +10 −10 Original line number Diff line number Diff line Loading @@ -15,16 +15,16 @@ class EntityTypeManagerStub extends EntityTypeManager implements EntityTypeManag public function __construct() { $languageDefault = new LanguageDefault(['id' => 'en', 'name' => 'English']); UnitTestHelpers::addToContainer('language_manager', new LanguageManager($languageDefault)); UnitTestHelpers::addToContainer('entity_field.manager', new EntityFieldManagerStub()); UnitTestHelpers::addToContainer('entity_type.bundle.info', new EntityTypeBundleInfoStub()); UnitTestHelpers::addToContainer('entity.query.sql', new EntityQueryServiceStub()); UnitTestHelpers::addToContainer('string_translation', UnitTestHelpers::getStringTranslationStub()); UnitTestHelpers::addToContainer('plugin.manager.field.field_type', new FieldTypeManagerStub()); UnitTestHelpers::addToContainer('typed_data_manager', new TypedDataManagerStub()); UnitTestHelpers::addToContainer('uuid', new PhpUuid()); UnitTestHelpers::initService('language_manager', new LanguageManager($languageDefault)); UnitTestHelpers::initService('entity_field.manager', new EntityFieldManagerStub()); UnitTestHelpers::initService('entity_type.bundle.info', new EntityTypeBundleInfoStub()); UnitTestHelpers::initService('entity.query.sql', new EntityQueryServiceStub()); UnitTestHelpers::initService('string_translation', UnitTestHelpers::getStringTranslationStub()); UnitTestHelpers::initService('plugin.manager.field.field_type', new FieldTypeManagerStub()); UnitTestHelpers::initService('typed_data_manager', new TypedDataManagerStub()); UnitTestHelpers::initService('uuid', new PhpUuid()); /** @var \Drupal\Core\Entity\EntityRepositoryInterface|\PHPUnit\Framework\MockObject\MockObject $entityRepository */ $entityRepository = UnitTestHelpers::addToContainer('entity.repository', UnitTestHelpers::createMock(EntityRepositoryInterface::class)); $entityRepository = UnitTestHelpers::initService('entity.repository', UnitTestHelpers::createMock(EntityRepositoryInterface::class)); $entityRepository ->method('loadEntityByUuid') ->willReturnCallback(function ($entityTypeId, $uuid) { Loading @@ -37,7 +37,7 @@ class EntityTypeManagerStub extends EntityTypeManager implements EntityTypeManag ->method('getTranslationFromContext') ->will(UnitTestCaseWrapper::getInstance()->returnArgument(0)); // UnitTestHelpers::addToContainer('entity_type.manager', $this); // UnitTestHelpers::initService('entity_type.manager', $this); } public function findDefinitions() { Loading
src/UnitTestHelpers.php +166 −104 Original line number Diff line number Diff line Loading @@ -7,7 +7,6 @@ use Drupal\Core\Cache\CacheTagsInvalidatorInterface; use Drupal\Core\Database\Query\ConditionInterface as QueryConditionInterface; use Drupal\Core\DependencyInjection\ContainerBuilder; use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Entity\EntityStorageInterface; use Drupal\Core\Entity\Query\ConditionInterface as EntityQueryConditionInterface; use Drupal\test_helpers\Stub\ModuleHandlerStub; use Drupal\test_helpers\Stub\TokenStub; Loading @@ -18,6 +17,8 @@ use PHPUnit\Framework\MockObject\MockObject; use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException; use Symfony\Component\Yaml\Yaml; // This trick is to prevent 'Undefined constant' warnings in code sniffers. defined('DRUPAL_ROOT') || define('DRUPAL_ROOT', ''); /** * Helper functions to simplify writing of Unit Tests. */ Loading @@ -33,6 +34,14 @@ class UnitTestHelpers { 'module_handler' => ModuleHandlerStub::class, ]; /** * The list of implemented custom stubs for services. */ const SERVICES_CUSTOM_STUBS_CALLBACKS = [ 'string_translation' => [self::class, 'getStringTranslationStub'], 'class_resolver' => [self::class, 'getClassResolverStub'], ]; /** * Gets a protected method from a class using reflection. */ Loading Loading @@ -144,7 +153,7 @@ class UnitTestHelpers { * @return object * The initialized class instance. */ public static function doTestCreateAndConstruct($class, array $createArguments = []): object { public static function createService($class, array $createArguments = []): object { $container = UnitTestHelpers::getContainer(); $classInstance = $class::create($container, ...$createArguments); $className = is_string($class) ? $class : get_class($class); Loading @@ -152,69 +161,6 @@ class UnitTestHelpers { return $classInstance; } /** * Performs matching of passed conditions with the query. */ public static function queryIsSubsetOf(object $query, object $queryExpected, $onlyListed = FALSE): bool { // @todo add checks for range, sort and other query parameters. return self::matchConditions($query->condition, $queryExpected->condition, $onlyListed); } /** * Performs matching of passed conditions with the query. */ public static function matchConditions(object $conditionsObject, object $conditionsExpectedObject, $onlyListed = FALSE): bool { if ($conditionsObject instanceof EntityQueryConditionInterface) { if (strcasecmp($conditionsObject->getConjunction(), $conditionsExpectedObject->getConjunction()) != 0) { return FALSE; } } elseif ($conditionsObject instanceof QueryConditionInterface) { if (strcasecmp($conditionsObject->conditions()['#conjunction'], $conditionsExpectedObject->conditions()['#conjunction']) != 0) { return FALSE; } } else { throw new \Exception("Conditions should implement Drupal\Core\Entity\Query\ConditionInterface or Drupal\Core\Database\Query\ConditionInterface."); } $conditions = $conditionsObject->conditions(); unset($conditions['#conjunction']); $conditionsExpected = $conditionsExpectedObject->conditions(); unset($conditionsExpected['#conjunction']); $conditionsFound = []; foreach ($conditions as $condition) { foreach ($conditionsExpected as $conditionsExpectedDelta => $conditionExpected) { if (is_object($condition['field']) || is_object($conditionExpected['field'])) { if (!is_object($condition['field']) || !is_object($conditionExpected['field'])) { continue; } return self::matchConditions($condition['field'], $conditionExpected['field'], $onlyListed); } if (self::isNestedArraySubsetOf($condition, $conditionExpected)) { $conditionsFound[$conditionsExpectedDelta] = TRUE; } } } if (count($conditionsFound) != count($conditionsExpected)) { return FALSE; } if ($onlyListed && (count($conditions) != count($conditionsExpected))) { return FALSE; } return TRUE; } /** * Performs check if the actial array is a subset of expected. */ public static function isNestedArraySubsetOf($array, $subset): bool { if (!is_array($array) || !is_array($subset)) { return FALSE; } $result = array_uintersect_assoc($subset, $array, self::class . '::isValueSubsetOfCallback'); return $result == $subset; } /** * Gets a Drupal services container, or creates a new one. */ Loading @@ -227,13 +173,22 @@ class UnitTestHelpers { } /** * Adds a new service to the Drupal container, if exists - reuse existing. * Initializes a new service and adds to the Drupal container, if not exists. */ public static function addToContainer(string $serviceName, object $class, bool $override = FALSE): object { public static function initService(string $serviceName, $class = NULL, bool $override = FALSE): object { $container = self::getContainer(); $currentService = $container->has($serviceName) ? $container->get($serviceName) : new \stdClass(); if ($class === NULL) { $class = self::getServiceStub($serviceName); } elseif (is_string($class)) { $class = self::createMock($class); } elseif (!is_object($class)) { throw new \Exception("Class should be an object, string as path to class, or NULL."); } if ( (get_class($currentService) !== get_class($class)) || $override Loading @@ -244,23 +199,22 @@ class UnitTestHelpers { } /** * Gets a service class by name, using Drupal defaults or a custom YAML file. * Initializes services with creating mocks/stubs for not passed classes. */ public static function getServiceClassByName(string $serviceName, string $servicesYamlFile = NULL): string { if ($servicesYamlFile) { // @phpcs-ignore $services = Yaml::parseFile(DRUPAL_ROOT . '/' . $servicesYamlFile)['services']; $serviceClass = $services[$serviceName]['class'] ?? FALSE; public static function initServices(array $services, $clearContainer = FALSE): void { if ($clearContainer) { UnitTestHelpers::getContainer(TRUE); } foreach ($services as $key => $value) { // If we have only a service name - just reuse the default behavior. if (is_int($key)) { self::initService($value); } // If we have a service name in key and class in value - pass the class. else { require_once dirname(__FILE__) . '/includes/DrupalCoreServicesMap.data'; // @php-ignore $serviceClass = DRUPAL_CORE_SERVICES_MAP[$serviceName] ?? FALSE; self::initService($key, $value); } if (!$serviceClass) { throw new \Exception("Service '$serviceName' is missing in the list."); } return $serviceClass; } /** Loading @@ -269,7 +223,7 @@ class UnitTestHelpers { public static function createServiceMock(string $serviceName, string $servicesYamlFile = NULL): MockObject { $serviceClass = self::getServiceClassByName($serviceName, $servicesYamlFile); $service = UnitTestHelpers::createMock($serviceClass); self::addToContainer($serviceName, $service); self::initService($serviceName, $service); return $service; } Loading @@ -287,32 +241,23 @@ class UnitTestHelpers { } /** * Gets the class for a service, including current module implementations. * Gets a service class by name, using Drupal defaults or a custom YAML file. */ public static function getServiceStubClass(string $serviceName, bool $onlyCustomMocks = FALSE): object { if (isset(self::SERVICES_CUSTOM_STUBS[$serviceName])) { $serviceClass = self::SERVICES_CUSTOM_STUBS[$serviceName]; $service = new $serviceClass(); } elseif ($onlyCustomMocks) { throw new ServiceNotFoundException($serviceName); public static function getServiceClassByName(string $serviceName, string $servicesYamlFile = NULL): string { if ($servicesYamlFile) { $services = Yaml::parseFile(DRUPAL_ROOT . '/' . $servicesYamlFile)['services']; $serviceClass = $services[$serviceName]['class'] ?? FALSE; } else { $service = UnitTestHelpers::createServiceMock($serviceName); } return $service; require_once dirname(__FILE__) . '/includes/DrupalCoreServicesMap.data'; // This trick is to prevent 'Undefined constant' warnings in code sniffers. defined('DRUPAL_CORE_SERVICES_MAP') || define('DRUPAL_CORE_SERVICES_MAP', ''); $serviceClass = DRUPAL_CORE_SERVICES_MAP[$serviceName] ?? FALSE; } /** * Gets the service from the Drupal container, or creates a new one. */ public static function getOrCreateService(string $serviceName, $class): object { $container = UnitTestHelpers::getContainer(); if (!$container->has($serviceName)) { $container->set($serviceName, $class); \Drupal::setContainer($container); if (!$serviceClass) { throw new \Exception("Service '$serviceName' is missing in the list."); } return $container->get($serviceName); return $serviceClass; } /** Loading @@ -326,7 +271,7 @@ class UnitTestHelpers { /** * Gets or initializes an Entity Storage for a given Entity class name. */ public static function getEntityStorageStub(string $entityClassName): EntityStorageInterface { public static function getEntityStorageStub(string $entityClassName): EntityStorageStub { return self::getServiceStub('entity_type.manager')->stubGetOrCreateStorage($entityClassName); } Loading @@ -337,6 +282,73 @@ class UnitTestHelpers { self::getServiceStub('entity_type.manager'); } /* ************************************************************************ * * Helpers for queries. * ************************************************************************ */ /** * Performs matching of passed conditions with the query. */ public static function queryIsSubsetOf(object $query, object $queryExpected, $onlyListed = FALSE): bool { // @todo add checks for range, sort and other query parameters. return self::matchConditions($query->condition, $queryExpected->condition, $onlyListed); } /** * Performs matching of passed conditions with the query. */ public static function matchConditions(object $conditionsObject, object $conditionsExpectedObject, $onlyListed = FALSE): bool { if ($conditionsObject instanceof EntityQueryConditionInterface) { if (strcasecmp($conditionsObject->getConjunction(), $conditionsExpectedObject->getConjunction()) != 0) { return FALSE; } } elseif ($conditionsObject instanceof QueryConditionInterface) { if (strcasecmp($conditionsObject->conditions()['#conjunction'], $conditionsExpectedObject->conditions()['#conjunction']) != 0) { return FALSE; } } else { throw new \Exception("Conditions should implement Drupal\Core\Entity\Query\ConditionInterface or Drupal\Core\Database\Query\ConditionInterface."); } $conditions = $conditionsObject->conditions(); unset($conditions['#conjunction']); $conditionsExpected = $conditionsExpectedObject->conditions(); unset($conditionsExpected['#conjunction']); $conditionsFound = []; foreach ($conditions as $condition) { foreach ($conditionsExpected as $conditionsExpectedDelta => $conditionExpected) { if (is_object($condition['field']) || is_object($conditionExpected['field'])) { if (!is_object($condition['field']) || !is_object($conditionExpected['field'])) { continue; } return self::matchConditions($condition['field'], $conditionExpected['field'], $onlyListed); } if (self::isNestedArraySubsetOf($condition, $conditionExpected)) { $conditionsFound[$conditionsExpectedDelta] = TRUE; } } } if (count($conditionsFound) != count($conditionsExpected)) { return FALSE; } if ($onlyListed && (count($conditions) != count($conditionsExpected))) { return FALSE; } return TRUE; } /** * Performs check if the actial array is a subset of expected. */ public static function isNestedArraySubsetOf($array, $subset): bool { if (!is_array($array) || !is_array($subset)) { return FALSE; } $result = array_uintersect_assoc($subset, $array, self::class . '::isValueSubsetOfCallback'); return $result == $subset; } /* ************************************************************************ * * Wrappers for UnitTestCase functions to make them available statically. * ************************************************************************ */ Loading Loading @@ -417,10 +429,60 @@ class UnitTestHelpers { return ($value == $expected) ? 0 : -1; } /** * Gets the class for a service, including current module implementations. */ private static function getServiceStubClass(string $serviceName, bool $onlyCustomMocks = FALSE): object { if (isset(self::SERVICES_CUSTOM_STUBS[$serviceName])) { $serviceClass = self::SERVICES_CUSTOM_STUBS[$serviceName]; $service = new $serviceClass(); } elseif (isset(self::SERVICES_CUSTOM_STUBS_CALLBACKS[$serviceName])) { $serviceClassCallback = self::SERVICES_CUSTOM_STUBS_CALLBACKS[$serviceName]; $service = call_user_func_array($serviceClassCallback, []); } elseif ($onlyCustomMocks) { throw new ServiceNotFoundException($serviceName); } else { $service = UnitTestHelpers::createServiceMock($serviceName); } return $service; } /** * Disables a construtor calls to allow only static calls. */ private function __construct() { } /* ************************************************************************ * * Deprecations. * ************************************************************************ */ /** * Tests simple create() and __construct() functions. * * @deprecated in test_helpers:1.0.0-alpha7 and is removed from * test_helpers:1.0.0-beta1. Use UnitTestHelpers::initService(). * * @see https://www.drupal.org/project/test_helpers/issues/3315975 */ public static function doTestCreateAndConstruct($class, array $createArguments = []): object { return self::createService($class, $createArguments); } /** * Adds a new service to the Drupal container, if exists - reuse existing. * * @deprecated in test_helpers:1.0.0-alpha7 and is removed from * test_helpers:1.0.0-beta1. Use UnitTestHelpers::initService(). * * @see https://www.drupal.org/project/test_helpers/issues/3315975 */ public static function addToContainer(string $serviceName, $class = NULL, bool $override = FALSE): object { @trigger_error('Function addToContainer is renamed to initService in test_helpers:1.0.0-alpha7.', E_USER_DEPRECATED); return self::initService($serviceName, $class, $override); } }
tests/src/Unit/UnitTestHelpersTest.php +54 −0 Original line number Diff line number Diff line Loading @@ -2,10 +2,15 @@ namespace Drupal\Tests\test_helpers\Unit; use Drupal\Core\Entity\Controller\EntityController; use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Entity\EntityTypeInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Routing\UrlGenerator; use Drupal\test_helpers\UnitTestHelpers; use Drupal\Tests\UnitTestCase; use PHPUnit\Framework\MockObject\MethodNameAlreadyConfiguredException; use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException; /** * Tests UnitTestHelpers functions. Loading @@ -19,6 +24,7 @@ class UnitTestHelpersTest extends UnitTestCase { * @covers ::getMockedMethod */ public function testGetMockedMethod() { /** @var \Drupal\Core\Entity\EntityInterface|\PHPUnit\Framework\MockObject\MockObject $mock */ $mock = $this->createMock(EntityInterface::class); $mock->method('label')->willReturn('foo'); $mock->method('id')->willReturn('42'); Loading Loading @@ -59,4 +65,52 @@ class UnitTestHelpersTest extends UnitTestCase { $this->assertSame(777, $mock->id()); } /** * @covers ::initServices */ public function testInitServices() { /** @var \Drupal\Core\Entity\EntityTypeInterface|\PHPUnit\Framework\MockObject\MockObject $entityType */ $entityType = $this->createMock(EntityTypeInterface::class); $entityType->method('getSingularLabel')->willReturn('my entity'); /** @var \Drupal\Core\Entity\EntityTypeManagerInterface|\PHPUnit\Framework\MockObject\MockObject $entityTypeManager */ $entityTypeManager = $this->createMock(EntityTypeManagerInterface::class); $entityTypeManager->method('getDefinition')->willReturn($entityType); UnitTestHelpers::initServices([ 'entity_type.manager' => $entityTypeManager, 'entity_type.bundle.info', 'renderer', 'string_translation', 'url_generator' => UrlGenerator::class, ]); // Checking initialized services. try { $service = UnitTestHelpers::doTestCreateAndConstruct(EntityController::class); $this->fail('Previous line should throw an exception.'); } catch (ServiceNotFoundException $e) { $this->assertEquals('You have requested a non-existent service "entity.repository".', $e->getMessage()); } UnitTestHelpers::initServices(['entity.repository']); // Testing the behavior on a real service with the 'create' function. $service = UnitTestHelpers::doTestCreateAndConstruct(EntityController::class); $result = $service->addTitle('my_entity'); $this->assertSame('Add my entity', $result->__toString()); // Checking resetting of the container. UnitTestHelpers::initServices(['entity.repository'], TRUE); try { $service = UnitTestHelpers::doTestCreateAndConstruct(EntityController::class); $this->fail('Previous line should throw an exception.'); } catch (ServiceNotFoundException $e) { $this->assertStringStartsWith('You have requested a non-existent service', $e->getMessage()); } } }